10 files changed
+1445
-496
lib/core/models/enums.dart
+18
@@ -44,12 +44,23 @@ class EngineMeta {
44
accentColor: 0xFFE91E63,
45
);
46
47
+ static const piper = EngineMeta(
48
+ name: 'Piper TTS',
49
+ tagline: 'Open-source neural voices',
50
+ sizeMb: 50,
51
+ ramMb: 100,
52
+ voiceCount: 2,
53
+ accentColor: 0xFF4CAF50,
54
+ );
55
+
56
static EngineMeta forEngine(EngineId engine) {
57
switch (engine) {
58
case EngineId.kitten:
59
return kitten;
60
case EngineId.kokoro:
61
return kokoro;
62
+ case EngineId.piper:
63
+ return piper;
64
default:
65
return system;
66
}
@@ -78,6 +89,8 @@ List<Voice> voicesForEngine(EngineId engine) {
89
return kittenVoices;
90
case EngineId.kokoro:
91
return kokoroVoices;
92
+ case EngineId.piper:
93
+ return piperVoices;
94
default:
95
return [];
96
}
@@ -109,6 +122,11 @@ const List<Voice> kokoroVoices = [
122
Voice(id: 'bm_lewis', name: 'Lewis', engine: EngineId.kokoro, language: 'en', gender: 'm'),
123
];
124
125
+const List<Voice> piperVoices = [
126
+ Voice(id: 'en_US-lessac-medium', name: 'Lessac (US)', engine: EngineId.piper, language: 'en', gender: 'f'),
127
+ Voice(id: 'en_US-ryan-medium', name: 'Ryan (US)', engine: EngineId.piper, language: 'en', gender: 'm'),
128
+];
129
+
130
enum ServerType { lmStudio, openAICompatible, ollama, openRouter, onDevice }
131
132
enum ConnectionStatus { connected, disconnected, checking, error }
lib/features/chat/views/chat_screen.dart
+25
-21
@@ -24,6 +24,7 @@ import 'package:localmind/features/conversations/providers/conversation_provider
24
import 'package:localmind/features/models/screens/model_picker_sheet.dart';
25
import 'package:localmind/features/personas/providers/personas_providers.dart';
26
import 'package:localmind/features/servers/providers/server_providers.dart';
27
+import 'package:localmind/features/tts/views/components/tts_player_bar.dart';
28
29
class ChatScreen extends ConsumerStatefulWidget {
30
const ChatScreen({super.key});
@@ -212,6 +213,9 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
213
),
214
),
215
const NotificationPermissionBanner(),
216
+ const TtsPlayerBar(
217
+ margin: EdgeInsets.symmetric(horizontal: 16, vertical: 4),
218
+ ),
219
if (connectionStatus == ConnectionStatus.disconnected ||
220
connectionStatus == ConnectionStatus.error)
221
_ConnectionBanner(status: connectionStatus),
@@ -282,31 +286,31 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
286
!chatState.isStreaming &&
287
ref.read(smartRepliesProvider).isNotEmpty,
288
),
285
- if (!chatState.isStreaming)
286
- Positioned(
287
- bottom: 90,
288
- left: 0,
289
- right: 0,
290
- child: _SmartReplyChips(
291
- onSend: (message) {
292
- ref.read(chatProvider.notifier).sendMessage(message);
293
- },
294
- ),
295
- ),
289
+
290
Positioned(
291
bottom: 0,
292
left: 0,
293
right: 0,
300
- child: ChatInputBar(
301
- isStreaming: chatState.isStreaming,
302
- onSend: (message, {attachments}) {
303
- ref
304
- .read(chatProvider.notifier)
305
- .sendMessage(message, attachments: attachments);
306
- },
307
- onStop: () {
308
- ref.read(chatProvider.notifier).cancelStream();
309
- },
294
+ child: Stack(
295
+ children: [
296
+ if (!chatState.isStreaming)
297
+ _SmartReplyChips(
298
+ onSend: (message) {
299
+ ref.read(chatProvider.notifier).sendMessage(message);
300
+ },
301
+ ),
302
+ ChatInputBar(
303
+ isStreaming: chatState.isStreaming,
304
+ onSend: (message, {attachments}) {
305
+ ref
306
+ .read(chatProvider.notifier)
307
+ .sendMessage(message, attachments: attachments);
308
+ },
309
+ onStop: () {
310
+ ref.read(chatProvider.notifier).cancelStream();
311
+ },
312
+ ),
313
+ ],
314
),
315
),
316
],
lib/features/sidebar/sidebar_widget.dart
+5
-225
@@ -4,11 +4,12 @@ import 'package:go_router/go_router.dart';
4
import 'package:hugeicons/hugeicons.dart';
5
import 'package:shadcn_ui/shadcn_ui.dart';
6
7
-import '../../core/routes/app_routes.dart';
7
import '../../core/providers/app_providers.dart';
8
+import '../../core/routes/app_routes.dart';
9
import '../../core/theme/app_theme.dart';
10
import '../chat/providers/chat_providers.dart';
11
import '../tts/providers/tts_providers.dart' as tts;
12
+import '../tts/views/components/tts_player_bar.dart';
13
import 'components/active_server_indicator.dart';
14
import 'components/conversation_drawer_header.dart';
15
import 'components/drawer_nav_item.dart';
@@ -102,8 +103,8 @@ class SidebarWidget extends ConsumerWidget {
103
context.go(AppRoutes.onDeviceModels);
104
},
105
),
105
- _SidebarNavItem(
106
- icon: const Icon(Icons.record_voice_over, size: 20),
106
+ DrawerNavItem(
107
+ iconData: HugeIcons.strokeRoundedVoice,
108
label: 'TTS Models',
109
isSelected: isTtsModels,
110
onTap: () {
@@ -157,7 +158,7 @@ class SidebarWidget extends ConsumerWidget {
158
),
159
160
// TTS Mini Player (only visible when TTS is active)
160
- const _TtsPlayerBar(),
161
+ const TtsPlayerBar(),
162
const SizedBox(height: 4),
163
164
// Bottom Section
@@ -197,224 +198,3 @@ class SidebarWidget extends ConsumerWidget {
198
}
199
}
200
}
200
-
201
-/// Mini TTS player bar shown in the sidebar when speech is playing or
202
-/// initializing. Displays a waveform icon, a content preview, and a stop button.
203
-class _TtsPlayerBar extends ConsumerWidget {
204
- const _TtsPlayerBar();
205
-
206
- @override
207
- Widget build(BuildContext context, WidgetRef ref) {
208
- final ttsState = ref.watch(tts.ttsProvider);
209
- ref.listen(tts.ttsProvider, (prev, next) {
210
- if (next.error != null && prev?.error != next.error) {
211
- ScaffoldMessenger.of(context).showSnackBar(
212
- SnackBar(
213
- content: Text(next.error!),
214
- backgroundColor: Colors.red,
215
- duration: const Duration(seconds: 3),
216
- ),
217
- );
218
- }
219
- });
220
- final theme = Theme.of(context);
221
- final isDark = theme.brightness == Brightness.dark;
222
-
223
- if (!ttsState.isSpeaking && !ttsState.isInitializing) {
224
- return const SizedBox.shrink();
225
- }
226
-
227
- final preview = _truncateContent(ttsState.playingContent);
228
-
229
- return AnimatedSize(
230
- duration: const Duration(milliseconds: 200),
231
- curve: Curves.easeInOut,
232
- child: Container(
233
- margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
234
- padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
235
- decoration: BoxDecoration(
236
- color: isDark ? const Color(0xFF1E293B) : const Color(0xFFEFF6FF),
237
- borderRadius: BorderRadius.circular(10),
238
- border: Border.all(
239
- color: isDark ? const Color(0xFF334155) : const Color(0xFFBFDBFE),
240
- ),
241
- ),
242
- child: Row(
243
- children: [
244
- _AnimatedWaveIndicator(isActive: ttsState.isSpeaking),
245
- const SizedBox(width: 10),
246
- Expanded(
247
- child: Column(
248
- crossAxisAlignment: CrossAxisAlignment.start,
249
- mainAxisSize: MainAxisSize.min,
250
- children: [
251
- Text(
252
- preview ??
253
- (ttsState.isInitializing ? 'Loading...' : 'Playing'),
254
- style: TextStyle(
255
- fontSize: 12,
256
- fontWeight: FontWeight.w500,
257
- color: isDark ? Colors.white70 : Colors.black87,
258
- ),
259
- maxLines: 1,
260
- overflow: TextOverflow.ellipsis,
261
- ),
262
- if (ttsState.isInitializing)
263
- Text(
264
- 'Initializing TTS...',
265
- style: TextStyle(
266
- fontSize: 10,
267
- color: isDark ? Colors.white38 : Colors.black45,
268
- ),
269
- ),
270
- ],
271
- ),
272
- ),
273
- const SizedBox(width: 8),
274
- GestureDetector(
275
- onTap: () => ref.read(tts.ttsProvider.notifier).stop(),
276
- child: Tooltip(
277
- message: 'Stop',
278
- child: Container(
279
- padding: const EdgeInsets.all(4),
280
- decoration: BoxDecoration(
281
- color: Colors.red.withValues(alpha: 0.1),
282
- borderRadius: BorderRadius.circular(6),
283
- ),
284
- child: Icon(
285
- Icons.stop_circle,
286
- size: 20,
287
- color: isDark ? Colors.red[300] : Colors.red[600],
288
- ),
289
- ),
290
- ),
291
- ),
292
- ],
293
- ),
294
- ),
295
- );
296
- }
297
-
298
- String? _truncateContent(String? content) {
299
- if (content == null || content.isEmpty) return null;
300
- if (content.length <= 50) return content;
301
- return '${content.substring(0, 47)}...';
302
- }
303
-}
304
-
305
-/// An animated audio wave indicator with a subtle bounce effect.
306
-class _AnimatedWaveIndicator extends StatefulWidget {
307
- final bool isActive;
308
- const _AnimatedWaveIndicator({required this.isActive});
309
-
310
- @override
311
- State<_AnimatedWaveIndicator> createState() => _AnimatedWaveIndicatorState();
312
-}
313
-
314
-class _AnimatedWaveIndicatorState extends State<_AnimatedWaveIndicator>
315
- with SingleTickerProviderStateMixin {
316
- late AnimationController _controller;
317
-
318
- @override
319
- void initState() {
320
- super.initState();
321
- _controller = AnimationController(
322
- duration: const Duration(milliseconds: 800),
323
- vsync: this,
324
- );
325
- if (widget.isActive) {
326
- _controller.repeat(reverse: true);
327
- }
328
- }
329
-
330
- @override
331
- void didUpdateWidget(_AnimatedWaveIndicator oldWidget) {
332
- super.didUpdateWidget(oldWidget);
333
- if (widget.isActive && !_controller.isAnimating) {
334
- _controller.repeat(reverse: true);
335
- } else if (!widget.isActive && _controller.isAnimating) {
336
- _controller.stop();
337
- }
338
- }
339
-
340
- @override
341
- void dispose() {
342
- _controller.dispose();
343
- super.dispose();
344
- }
345
-
346
- @override
347
- Widget build(BuildContext context) {
348
- return AnimatedBuilder(
349
- animation: _controller,
350
- builder: (context, child) {
351
- final scale = widget.isActive ? 1.0 + _controller.value * 0.15 : 1.0;
352
- return Transform.scale(
353
- scale: scale,
354
- child: Icon(
355
- Icons.graphic_eq,
356
- size: 18,
357
- color: widget.isActive ? const Color(0xFF3B82F6) : Colors.grey,
358
- ),
359
- );
360
- },
361
- );
362
- }
363
-}
364
-
365
-/// A sidebar navigation item that accepts any Widget as an icon.
366
-/// Used when HugeIcons doesn't have a suitable icon.
367
-class _SidebarNavItem extends StatelessWidget {
368
- const _SidebarNavItem({
369
- required this.icon,
370
- required this.label,
371
- required this.isSelected,
372
- required this.onTap,
373
- });
374
-
375
- final Widget icon;
376
- final String label;
377
- final bool isSelected;
378
- final VoidCallback onTap;
379
-
380
- @override
381
- Widget build(BuildContext context) {
382
- final theme = Theme.of(context);
383
- final isDark = theme.brightness == Brightness.dark;
384
-
385
- return Padding(
386
- padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
387
- child: InkWell(
388
- onTap: onTap,
389
- borderRadius: BorderRadius.circular(12),
390
- child: Container(
391
- padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
392
- decoration: BoxDecoration(
393
- color: isSelected
394
- ? (isDark
395
- ? theme.colorScheme.primary.withAlpha(30)
396
- : theme.colorScheme.primary.withAlpha(20))
397
- : Colors.transparent,
398
- borderRadius: BorderRadius.circular(12),
399
- ),
400
- child: Row(
401
- children: [
402
- icon,
403
- const SizedBox(width: 16),
404
- Text(
405
- label,
406
- style: TextStyle(
407
- fontSize: 14,
408
- fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
409
- color: isSelected
410
- ? (isDark ? Colors.white : theme.colorScheme.primary)
411
- : (isDark ? Colors.white70 : Colors.black87),
412
- ),
413
- ),
414
- ],
415
- ),
416
- ),
417
- ),
418
- );
419
- }
420
-}
lib/features/tts/data/piper_tts_downloader.dart
new
+144
@@ -0,0 +1,144 @@
1
+import 'dart:async';
2
+import 'dart:io';
3
+
4
+import 'package:dio/dio.dart';
5
+
6
+import 'model_downloader.dart';
7
+import 'piper_tts_model.dart';
8
+
9
+class PiperTtsDownloader {
10
+ final Dio _dio;
11
+ final ModelDownloader _baseDownloader;
12
+ final Map<PiperTtsModelVariant, CancelToken> _cancelTokens = {};
13
+
14
+ PiperTtsDownloader()
15
+ : _dio = Dio(),
16
+ _baseDownloader = ModelDownloader();
17
+
18
+ Future<Directory> _getVariantDir(PiperTtsModelVariant variant) async {
19
+ final ttsDir = await _baseDownloader.getTtsDir();
20
+ final dir = Directory('${ttsDir.path}/piper');
21
+ if (!await dir.exists()) {
22
+ await dir.create(recursive: true);
23
+ }
24
+ return dir;
25
+ }
26
+
27
+ Future<Set<PiperTtsModelVariant>> getDownloadedVariants() async {
28
+ final downloaded = <PiperTtsModelVariant>{};
29
+ for (final variant in PiperTtsModelVariant.values) {
30
+ final dir = await _getVariantDir(variant);
31
+ final modelFile = File('${dir.path}/${variant.modelFileName}');
32
+ final configFile = File('${dir.path}/${variant.configFileName}');
33
+ if (await modelFile.exists() && await configFile.exists()) {
34
+ downloaded.add(variant);
35
+ }
36
+ }
37
+ return downloaded;
38
+ }
39
+
40
+ Stream<PiperTtsFileProgress> downloadModel(
41
+ PiperTtsModelVariant variant,
42
+ ) async* {
43
+ final cancelToken = CancelToken();
44
+ _cancelTokens[variant] = cancelToken;
45
+
46
+ try {
47
+ final dir = await _getVariantDir(variant);
48
+
49
+ // Download .onnx
50
+ yield* _downloadFile(
51
+ variant.modelUrl,
52
+ '${dir.path}/${variant.modelFileName}',
53
+ variant.modelFileName,
54
+ cancelToken,
55
+ );
56
+
57
+ // Download .onnx.json
58
+ yield* _downloadFile(
59
+ variant.configUrl,
60
+ '${dir.path}/${variant.configFileName}',
61
+ variant.configFileName,
62
+ cancelToken,
63
+ );
64
+ } finally {
65
+ _cancelTokens.remove(variant);
66
+ }
67
+ }
68
+
69
+ Stream<PiperTtsFileProgress> _downloadFile(
70
+ String url,
71
+ String savePath,
72
+ String fileName,
73
+ CancelToken cancelToken,
74
+ ) async* {
75
+ // Optional: if it already exists and has size > 0, we could skip it.
76
+ // For simplicity, we just redownload to ensure it's complete,
77
+ // or you could check file size. We'll do a simple redownload.
78
+
79
+ int totalBytes = 1; // dummy initial
80
+ int receivedBytes = 0;
81
+
82
+ final controller = StreamController<PiperTtsFileProgress>();
83
+
84
+ try {
85
+ await _dio.download(
86
+ url,
87
+ savePath,
88
+ cancelToken: cancelToken,
89
+ onReceiveProgress: (received, total) {
90
+ if (total != -1) {
91
+ totalBytes = total;
92
+ } else if (totalBytes == 1) {
93
+ // Give it some arbitrary size if server doesn't report
94
+ totalBytes = 10 * 1024 * 1024;
95
+ }
96
+ receivedBytes = received;
97
+
98
+ if (!controller.isClosed) {
99
+ controller.add(
100
+ PiperTtsFileProgress(
101
+ fileName: fileName,
102
+ receivedBytes: receivedBytes,
103
+ totalBytes: totalBytes,
104
+ isComplete: false,
105
+ ),
106
+ );
107
+ }
108
+ },
109
+ );
110
+
111
+ if (!controller.isClosed) {
112
+ controller.add(
113
+ PiperTtsFileProgress(
114
+ fileName: fileName,
115
+ receivedBytes: receivedBytes,
116
+ totalBytes: totalBytes,
117
+ isComplete: true,
118
+ ),
119
+ );
120
+ }
121
+ } catch (e) {
122
+ if (e is! DioException || !CancelToken.isCancel(e)) {
123
+ rethrow;
124
+ }
125
+ } finally {
126
+ await controller.close();
127
+ }
128
+
129
+ yield* controller.stream;
130
+ }
131
+
132
+ void cancelDownload(PiperTtsModelVariant variant) {
133
+ _cancelTokens[variant]?.cancel('User cancelled');
134
+ _cancelTokens.remove(variant);
135
+ }
136
+
137
+ Future<void> deleteVariant(PiperTtsModelVariant variant) async {
138
+ final dir = await _getVariantDir(variant);
139
+ final modelFile = File('${dir.path}/${variant.modelFileName}');
140
+ final configFile = File('${dir.path}/${variant.configFileName}');
141
+ if (await modelFile.exists()) await modelFile.delete();
142
+ if (await configFile.exists()) await configFile.delete();
143
+ }
144
+}
lib/features/tts/data/piper_tts_model.dart
new
+74
@@ -0,0 +1,74 @@
1
+
2
+enum PiperTtsModelVariant {
3
+ enUsLessacMedium,
4
+ enUsRyanMedium,
5
+}
6
+
7
+extension PiperTtsModelVariantExtension on PiperTtsModelVariant {
8
+ String get id {
9
+ switch (this) {
10
+ case PiperTtsModelVariant.enUsLessacMedium:
11
+ return 'en_US-lessac-medium';
12
+ case PiperTtsModelVariant.enUsRyanMedium:
13
+ return 'en_US-ryan-medium';
14
+ }
15
+ }
16
+
17
+ String get displayName {
18
+ switch (this) {
19
+ case PiperTtsModelVariant.enUsLessacMedium:
20
+ return 'English (US) - Lessac (Medium)';
21
+ case PiperTtsModelVariant.enUsRyanMedium:
22
+ return 'English (US) - Ryan (Medium)';
23
+ }
24
+ }
25
+
26
+ String get modelFileName => '$id.onnx';
27
+ String get configFileName => '$id.onnx.json';
28
+
29
+ String get modelUrl {
30
+ switch (this) {
31
+ case PiperTtsModelVariant.enUsLessacMedium:
32
+ return 'https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_US/lessac/medium/en_US-lessac-medium.onnx';
33
+ case PiperTtsModelVariant.enUsRyanMedium:
34
+ return 'https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_US/ryan/medium/en_US-ryan-medium.onnx';
35
+ }
36
+ }
37
+
38
+ String get configUrl {
39
+ switch (this) {
40
+ case PiperTtsModelVariant.enUsLessacMedium:
41
+ return 'https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_US/lessac/medium/en_US-lessac-medium.onnx.json';
42
+ case PiperTtsModelVariant.enUsRyanMedium:
43
+ return 'https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_US/ryan/medium/en_US-ryan-medium.onnx.json';
44
+ }
45
+ }
46
+
47
+ int get totalSizeBytes {
48
+ switch (this) {
49
+ case PiperTtsModelVariant.enUsLessacMedium:
50
+ return 48 * 1024 * 1024; // ~48MB
51
+ case PiperTtsModelVariant.enUsRyanMedium:
52
+ return 48 * 1024 * 1024; // ~48MB
53
+ }
54
+ }
55
+}
56
+
57
+class PiperTtsFileProgress {
58
+ final String fileName;
59
+ final int receivedBytes;
60
+ final int totalBytes;
61
+ final bool isComplete;
62
+
63
+ const PiperTtsFileProgress({
64
+ required this.fileName,
65
+ required this.receivedBytes,
66
+ required this.totalBytes,
67
+ required this.isComplete,
68
+ });
69
+
70
+ double get fraction {
71
+ if (totalBytes <= 0) return 0;
72
+ return (receivedBytes / totalBytes).clamp(0.0, 1.0);
73
+ }
74
+}
lib/features/tts/providers/tts_model_providers.dart
+115
-1
@@ -1,5 +1,6 @@
1
import 'dart:async';
2
3
+import 'package:flutter/foundation.dart';
4
import 'package:flutter_riverpod/flutter_riverpod.dart';
5
6
import '../data/model_downloader.dart';
@@ -7,6 +8,8 @@ import '../data/kitten_tts_model.dart';
8
import '../data/kitten_tts_downloader.dart';
9
import '../data/kokoro_tts_model.dart';
10
import '../data/kokoro_tts_downloader.dart';
11
+import '../data/piper_tts_model.dart';
12
+import '../data/piper_tts_downloader.dart';
13
14
/// Provider for the KittenTTS downloader instance.
15
final kittenTtsDownloaderProvider = Provider<KittenTtsDownloader>((ref) {
@@ -18,6 +21,11 @@ final kokoroTtsDownloaderProvider = Provider<KokoroTtsDownloader>((ref) {
21
return KokoroTtsDownloader();
22
});
23
24
+/// Provider for the Piper TTS downloader instance.
25
+final piperTtsDownloaderProvider = Provider<PiperTtsDownloader>((ref) {
26
+ return PiperTtsDownloader();
27
+});
28
+
29
final modelDownloaderProvider = Provider<ModelDownloader>((ref) {
30
return ModelDownloader();
31
});
@@ -46,6 +54,13 @@ final downloadedKokoroTtsVariantsProvider =
54
return downloader.getDownloadedVariants();
55
});
56
57
+/// Async provider for the set of downloaded Piper variants.
58
+final downloadedPiperTtsVariantsProvider =
59
+ FutureProvider<Set<PiperTtsModelVariant>>((ref) async {
60
+ final downloader = ref.read(piperTtsDownloaderProvider);
61
+ return downloader.getDownloadedVariants();
62
+ });
63
+
64
// ── KittenTTS Download Progress ──
65
66
/// Notifier that tracks per-file download progress for KittenTTS models.
@@ -261,8 +276,107 @@ class KokoroTtsDownloadNotifier
276
}
277
}
278
279
+// ── Piper TTS Download Progress ──
280
+
281
+/// Notifier that tracks per-file download progress for Piper TTS models.
282
+final piperTtsDownloadProgressProvider = NotifierProvider<
283
+ PiperTtsDownloadNotifier,
284
+ Map<PiperTtsModelVariant, Map<String, PiperTtsFileProgress>>
285
+>(() => PiperTtsDownloadNotifier());
286
+
287
+class PiperTtsDownloadNotifier extends Notifier<
288
+ Map<PiperTtsModelVariant, Map<String, PiperTtsFileProgress>>
289
+> {
290
+ final Map<PiperTtsModelVariant, StreamSubscription<PiperTtsFileProgress>>
291
+ _subscriptions = {};
292
+
293
+ @override
294
+ Map<PiperTtsModelVariant, Map<String, PiperTtsFileProgress>> build() {
295
+ ref.onDispose(() {
296
+ for (final sub in _subscriptions.values) {
297
+ sub.cancel();
298
+ }
299
+ _subscriptions.clear();
300
+ });
301
+ return {};
302
+ }
303
+
304
+ Future<void> startDownload(PiperTtsModelVariant variant) async {
305
+ final currentVariantProgress = Map<String, PiperTtsFileProgress>.from(
306
+ state[variant] ?? {},
307
+ );
308
+
309
+ state = {...state, variant: currentVariantProgress};
310
+
311
+ final downloader = ref.read(piperTtsDownloaderProvider);
312
+
313
+ _subscriptions[variant]?.cancel();
314
+ _subscriptions[variant] = downloader.downloadModel(variant).listen(
315
+ (progress) {
316
+ final variantMap = Map<String, PiperTtsFileProgress>.from(
317
+ state[variant] ?? {},
318
+ );
319
+ variantMap[progress.fileName] = progress;
320
+ state = {...state, variant: variantMap};
321
+ },
322
+ onDone: () {
323
+ _subscriptions.remove(variant);
324
+ ref.invalidate(downloadedPiperTtsVariantsProvider);
325
+ },
326
+ onError: (e) {
327
+ _ModelLog.error(
328
+ 'Piper TTS download error for ${variant.displayName}: $e',
329
+ );
330
+ _subscriptions.remove(variant);
331
+ },
332
+ );
333
+ }
334
+
335
+ void cancelDownload(PiperTtsModelVariant variant) {
336
+ ref.read(piperTtsDownloaderProvider).cancelDownload(variant);
337
+ _subscriptions[variant]?.cancel();
338
+ _subscriptions.remove(variant);
339
+
340
+ final newState =
341
+ Map<PiperTtsModelVariant, Map<String, PiperTtsFileProgress>>.from(
342
+ state,
343
+ );
344
+ newState.remove(variant);
345
+ state = newState;
346
+ }
347
+
348
+ Future<void> deleteVariant(PiperTtsModelVariant variant) async {
349
+ cancelDownload(variant);
350
+ await ref.read(piperTtsDownloaderProvider).deleteVariant(variant);
351
+
352
+ final newState =
353
+ Map<PiperTtsModelVariant, Map<String, PiperTtsFileProgress>>.from(
354
+ state,
355
+ );
356
+ newState.remove(variant);
357
+ state = newState;
358
+
359
+ ref.invalidate(downloadedPiperTtsVariantsProvider);
360
+ }
361
+
362
+ bool isDownloading(PiperTtsModelVariant variant) {
363
+ final variantProgress = state[variant];
364
+ if (variantProgress == null || variantProgress.isEmpty) return false;
365
+ return !variantProgress.values.every((f) => f.isComplete);
366
+ }
367
+
368
+ double getOverallFraction(PiperTtsModelVariant variant) {
369
+ final variantProgress = state[variant];
370
+ if (variantProgress == null || variantProgress.isEmpty) return 0.0;
371
+ final total = variantProgress.values
372
+ .map((f) => f.fraction)
373
+ .fold<double>(0.0, (a, b) => a + b);
374
+ return total / variantProgress.length;
375
+ }
376
+}
377
+
378
class _ModelLog {
379
static void error(String message) {
266
- print('[TtsModel] ERROR: $message');
380
+ debugPrint('[TtsModel] ERROR: $message');
381
}
382
}
lib/features/tts/providers/tts_providers.dart
+295
-140
@@ -1,4 +1,6 @@
1
+import 'dart:async';
2
import 'dart:io';
3
+import 'dart:isolate';
4
import 'dart:typed_data';
5
6
import 'package:archive/archive.dart';
@@ -12,7 +14,10 @@ import 'package:path_provider/path_provider.dart';
14
import '../../../core/logger/app_logger.dart';
15
import '../../../core/models/enums.dart';
16
import '../../../core/providers/app_providers.dart';
17
+import '../data/kitten_tts_model.dart';
18
+import '../data/kokoro_tts_model.dart';
19
import 'tts_model_providers.dart';
20
+import 'tts_worker.dart';
21
22
final ttsProvider = NotifierProvider<TtsNotifier, TtsState>(() {
23
return TtsNotifier();
@@ -51,24 +56,46 @@ class TtsState {
56
}
57
58
class TtsNotifier extends Notifier<TtsState> {
54
- MeoKitten? _kitten;
55
- MeoKokoro? _kokoro;
56
- MeoPiper? _piper;
59
FlutterTts? _flutterTts;
60
final AudioPlayer _player = AudioPlayer();
61
bool _isPlayerDisposed = false;
62
+ bool _isStopping = false;
63
+ List<String> _chunks = [];
64
+ int _currentChunkIndex = 0;
65
+ EngineId? _currentEngine;
66
+ String? _currentVoice;
67
+
68
+ // Isolate worker fields
69
+ Isolate? _workerIsolate;
70
+ SendPort? _workerSendPort;
71
+ final ReceivePort _mainReceivePort = ReceivePort();
72
+ final Map<int, Uint8List> _synthesizedChunks = {};
73
+ int _lastEnqueuedChunk = -1;
74
+ static const int _lookAheadCount = 2;
75
+ Completer<void>? _initCompleter;
76
+ bool _isWorkerInitializing = false;
77
+ int _currentSessionId = 0;
78
79
@override
80
TtsState build() {
81
ref.keepAlive();
64
- final settings = ref.watch(settingsProvider);
82
+
83
+ // Listen for engine changes to update the state without rebuilding the whole notifier
84
+ ref.listen(settingsProvider, (previous, next) {
85
+ if (previous?.ttsEngine != next.ttsEngine) {
86
+ state = state.copyWith(activeEngine: next.ttsEngine);
87
+ }
88
+ });
89
+
90
_player.onPlayerComplete.listen((_) {
66
- _onPlaybackFinished();
91
+ _onChunkFinished();
92
});
93
+
94
+ _setupWorkerListener();
95
+
96
ref.onDispose(() {
69
- _kitten?.dispose();
70
- _kokoro?.dispose();
71
- _piper?.dispose();
97
+ _workerIsolate?.kill();
98
+ _mainReceivePort.close();
99
if (!_isPlayerDisposed) {
100
_isPlayerDisposed = true;
101
try {
@@ -77,11 +104,66 @@ class TtsNotifier extends Notifier<TtsState> {
104
}
105
});
106
107
+ final settings = ref.read(settingsProvider);
108
return TtsState(activeEngine: settings.ttsEngine);
109
}
110
111
+ void _setupWorkerListener() {
112
+ _mainReceivePort.listen((message) {
113
+ if (message is SendPort) {
114
+ _workerSendPort = message;
115
+ } else if (message is TtsWorkerResponse) {
116
+ if (message.isInitResponse) {
117
+ _initCompleter?.complete();
118
+ _initCompleter = null;
119
+ } else if (message.chunkIndex != null) {
120
+ if (message.sessionId != _currentSessionId) {
121
+ Log.debug('Ignoring stale chunk ${message.chunkIndex}');
122
+ return;
123
+ }
124
+ if (message.wavBytes != null) {
125
+ _synthesizedChunks[message.chunkIndex!] = message.wavBytes!;
126
+ // If this was the chunk we were waiting for, play it
127
+ if (message.chunkIndex == _currentChunkIndex && state.isSpeaking) {
128
+ _playNextChunk();
129
+ }
130
+ } else {
131
+ Log.error(
132
+ 'Worker error for chunk ${message.chunkIndex}: ${message.error}',
133
+ );
134
+ if (message.chunkIndex == _currentChunkIndex) {
135
+ _onChunkFinished();
136
+ }
137
+ }
138
+ } else if (message.error != null) {
139
+ Log.error('Worker general error: ${message.error}');
140
+ _initCompleter?.completeError(message.error!);
141
+ _initCompleter = null;
142
+ }
143
+ }
144
+ });
145
+ }
146
+
147
+ Future<void> _ensureWorker() async {
148
+ if (_workerIsolate != null) return;
149
+
150
+ Log.info('Spawning TTS worker isolate...');
151
+ _workerIsolate = await Isolate.spawn(ttsWorkerEntry, _mainReceivePort.sendPort);
152
+
153
+ // Wait for worker to send its SendPort
154
+ int attempts = 0;
155
+ while (_workerSendPort == null && attempts < 200) {
156
+ await Future.delayed(const Duration(milliseconds: 50));
157
+ attempts++;
158
+ }
159
+
160
+ if (_workerSendPort == null) {
161
+ throw StateError('Failed to establish communication with TTS worker');
162
+ }
163
+ }
164
+
165
void _onPlaybackFinished() {
84
- if (_isPlayerDisposed || !state.isSpeaking) return;
166
+ if (_isStopping) return;
167
state = state.copyWith(
168
isSpeaking: false,
169
isInitializing: false,
@@ -89,32 +171,103 @@ class TtsNotifier extends Notifier<TtsState> {
171
);
172
}
173
174
+ void _onChunkFinished() {
175
+ if (_isStopping) return;
176
+ _currentChunkIndex++;
177
+ if (_currentChunkIndex < _chunks.length) {
178
+ _playNextChunk();
179
+ } else {
180
+ _onPlaybackFinished();
181
+ }
182
+ }
183
+
184
+ List<String> _splitText(String text) {
185
+ if (text.length <= 250) return [text];
186
+
187
+ // Split by common sentence terminators but keep them
188
+ final regex = RegExp(r'(?<=[.!?])\s+');
189
+ final sentences = text.split(regex);
190
+
191
+ final List<String> chunks = [];
192
+ String currentChunk = "";
193
+
194
+ for (final sentence in sentences) {
195
+ if ((currentChunk.length + sentence.length) > 250 &&
196
+ currentChunk.isNotEmpty) {
197
+ chunks.add(currentChunk.trim());
198
+ currentChunk = "";
199
+ }
200
+
201
+ if (sentence.length > 250) {
202
+ // If a single sentence is too long, split it by commas or spaces
203
+ final subRegex = RegExp(r'(?<=[,;])\s+');
204
+ final parts = sentence.split(subRegex);
205
+ for (final part in parts) {
206
+ if ((currentChunk.length + part.length) > 250 &&
207
+ currentChunk.isNotEmpty) {
208
+ chunks.add(currentChunk.trim());
209
+ currentChunk = "";
210
+ }
211
+ currentChunk += "$part ";
212
+ }
213
+ } else {
214
+ currentChunk += "$sentence ";
215
+ }
216
+ }
217
+
218
+ if (currentChunk.trim().isNotEmpty) {
219
+ chunks.add(currentChunk.trim());
220
+ }
221
+
222
+ return chunks;
223
+ }
224
+
225
Future<void> _ensureEspeakData(String targetPath) async {
226
final dir = Directory(targetPath);
94
- if (await dir.exists()) {
95
- // Basic check: if it has espeak-ng-data, assume it's valid
96
- if (await Directory('$targetPath/espeak-ng-data').exists()) {
227
+ final espeakNgDataDir = Directory('$targetPath/espeak-ng-data');
228
+ final voicesDir = Directory('$targetPath/espeak-ng-data/voices');
229
+ Log.info('Checking espeak-data at $targetPath');
230
+ if (await espeakNgDataDir.exists() && await voicesDir.exists()) {
231
+ final entities = await espeakNgDataDir.list().toList();
232
+ if (entities.length > 5) {
233
+ Log.info('espeak-data looks valid with ${entities.length} entities.');
234
return;
235
}
236
}
237
101
- Log.info('Extracting espeak-data.zip to $targetPath...');
238
+ Log.info('espeak-data invalid or missing. (Re)extracting espeak-data.zip to $targetPath...');
239
+ if (await dir.exists()) {
240
+ try {
241
+ await dir.delete(recursive: true);
242
+ } catch (e) {
243
+ Log.error('Failed to clear old espeak-data: $e');
244
+ }
245
+ }
246
+ await dir.create(recursive: true);
247
try {
248
final data = await rootBundle.load('assets/espeak-data.zip');
249
final bytes = data.buffer.asUint8List();
105
- final archive = ZipDecoder().decodeBytes(bytes);
106
-
107
- for (final file in archive) {
108
- final filename = file.name;
109
- if (file.isFile) {
110
- final data = file.content as List<int>;
111
- final f = File('$targetPath/$filename');
112
- await f.create(recursive: true);
113
- await f.writeAsBytes(data);
114
- } else {
115
- await Directory('$targetPath/$filename').create(recursive: true);
250
+
251
+ await Isolate.run(() async {
252
+ final archive = ZipDecoder().decodeBytes(bytes);
253
+
254
+ for (final file in archive) {
255
+ String filename = file.name;
256
+ if (filename.startsWith('espeak-data/')) {
257
+ filename = filename.substring('espeak-data/'.length);
258
+ }
259
+ if (filename.isEmpty) continue;
260
+
261
+ if (file.isFile) {
262
+ final fileData = file.content as List<int>;
263
+ final f = File('$targetPath/$filename');
264
+ await f.create(recursive: true);
265
+ await f.writeAsBytes(fileData);
266
+ } else {
267
+ await Directory('$targetPath/$filename').create(recursive: true);
268
+ }
269
}
117
- }
270
+ });
271
Log.info('espeak-data extraction complete.');
272
} catch (e) {
273
Log.error('Failed to extract espeak-data: $e');
@@ -131,69 +284,82 @@ class TtsNotifier extends Notifier<TtsState> {
284
await _ensureEspeakData(resolvedEspeakPath);
285
}
286
287
+ await _ensureWorker();
288
+
289
+ if (_currentEngine == engineId && !_isWorkerInitializing) return;
290
+ _isWorkerInitializing = true;
291
+
292
+ String modelPath = '';
293
+ String voicesPath = '';
294
+
295
switch (engineId) {
296
case EngineId.kitten:
136
- if (_kitten != null) return;
137
- final kittenDl = ref.read(kittenTtsDownloaderProvider);
297
final variant = ref.read(settingsProvider).kittenTtsModelVariant;
139
- final modelPath = await kittenDl.getFilePath(
140
- variant,
141
- variant.modelFileName,
142
- );
143
- final voicesPath = await kittenDl.getFilePath(variant, 'voices.npz');
144
- if (modelPath == null || voicesPath == null) {
298
+ final kittenDl = ref.read(kittenTtsDownloaderProvider);
299
+ final mPath = await kittenDl.getFilePath(variant, variant.modelFileName);
300
+ final vPath = await kittenDl.getFilePath(variant, 'voices.npz');
301
+ if (mPath == null || vPath == null) {
302
throw StateError(
146
- 'Kitten model files not found. Please download them in the Model Manager.',
303
+ 'Kitten model files not found for ${variant.name}. Please download them in the Model Manager.',
304
);
305
}
149
- _kitten = MeoKitten(
150
- model: modelPath,
151
- voices: voicesPath,
152
- espeakData: resolvedEspeakPath,
153
- );
306
+ modelPath = mPath;
307
+ voicesPath = vPath;
308
break;
309
case EngineId.kokoro:
156
- if (_kokoro != null) return;
310
+ final variant = ref.read(settingsProvider).kokoroTtsModelVariant;
311
final kokoroDl = ref.read(kokoroTtsDownloaderProvider);
158
- final variant =
159
- ref.read(settingsProvider).kokoroTtsModelVariant;
160
- final modelPath = await kokoroDl.getModelPath(variant);
161
- final voicesDir = await kokoroDl.getVoicesDir(variant);
162
- if (modelPath == null) {
312
+ final mPath = await kokoroDl.getModelPath(variant);
313
+ final vDir = await kokoroDl.getVoicesDir(variant);
314
+ if (mPath == null) {
315
throw StateError(
164
- 'Kokoro model file not found. Please download it in the Model Manager.',
316
+ 'Kokoro model files not found for ${variant.name}. Please download them in the Model Manager.',
317
);
318
}
167
- _kokoro = MeoKokoro(
168
- model: modelPath,
169
- voices: voicesDir.path,
170
- espeakData: resolvedEspeakPath,
171
- );
319
+ modelPath = mPath;
320
+ voicesPath = vDir.path;
321
break;
322
case EngineId.piper:
174
- if (_piper != null) return;
323
final downloader = ref.read(modelDownloaderProvider);
324
final ttsDir = await downloader.getTtsDir();
177
- final modelPath = '${ttsDir.path}/piper/model.onnx';
178
- if (!await File(modelPath).exists()) {
179
- throw StateError('Piper model file not found.');
325
+ final piperDir = '${ttsDir.path}/piper';
326
+ if (!await Directory(piperDir).exists()) {
327
+ throw StateError('Piper models directory not found.');
328
}
181
- _piper = MeoPiper(model: modelPath, espeakData: resolvedEspeakPath);
329
+ modelPath = piperDir;
330
break;
331
default:
184
- break;
332
+ return;
333
}
334
+
335
+ _initCompleter = Completer<void>();
336
+ _workerSendPort!.send(
337
+ TtsInitRequest(
338
+ engineId: engineId,
339
+ modelPath: modelPath,
340
+ voicesPath: voicesPath,
341
+ espeakPath: resolvedEspeakPath,
342
+ ),
343
+ );
344
+
345
+ await _initCompleter!.future;
346
+ _currentEngine = engineId;
347
+ _isWorkerInitializing = false;
348
}
349
188
- Future<void> speak(String text) async {
350
+ Future<void> speak(String text, {EngineId? engineId, String? voiceId}) async {
351
if (text.trim().isEmpty) return;
352
353
+ _isStopping = false;
354
if (state.isSpeaking) {
355
await stop();
356
+ // Wait a bit for the player to fully stop
357
+ await Future.delayed(const Duration(milliseconds: 100));
358
+ _isStopping = false;
359
}
360
361
final settings = ref.read(settingsProvider);
196
- final engineId = settings.ttsEngine;
362
+ engineId ??= settings.ttsEngine;
363
364
if (engineId == EngineId.system) {
365
_flutterTts ??= FlutterTts();
@@ -219,14 +385,18 @@ class TtsNotifier extends Notifier<TtsState> {
385
);
386
rethrow;
387
} finally {
222
- state = state.copyWith(
223
- isSpeaking: false,
224
- playingContent: null,
225
- );
388
+ state = state.copyWith(isSpeaking: false, playingContent: null);
389
}
390
return;
391
}
392
393
+ _chunks = _splitText(text);
394
+ _currentChunkIndex = 0;
395
+ _currentVoice = voiceId;
396
+ _synthesizedChunks.clear();
397
+ _lastEnqueuedChunk = -1;
398
+ _currentSessionId++;
399
+
400
state = state.copyWith(
401
isSpeaking: true,
402
isInitializing: true,
@@ -238,32 +408,7 @@ class TtsNotifier extends Notifier<TtsState> {
408
try {
409
await _initEngine(engineId);
410
state = state.copyWith(isInitializing: false);
241
-
242
- final voiceId = settings.ttsVoiceId ?? 'Luna';
243
- final speaker = Speaker(voice: voiceId, speed: settings.ttsSpeed);
244
-
245
- Float32List? pcm;
246
- int sampleRate = 22050;
247
-
248
- if (engineId == EngineId.kitten) {
249
- pcm = await _kitten?.speak(text, speaker: speaker);
250
- sampleRate = 24000; // Kitten default
251
- } else if (engineId == EngineId.kokoro) {
252
- pcm = await _kokoro?.speak(text, speaker: speaker);
253
- sampleRate = 24000; // Kokoro default
254
- } else if (engineId == EngineId.piper) {
255
- pcm = await _piper?.speak(text, speaker: speaker);
256
- sampleRate = 22050; // Piper default often 22050
257
- }
258
-
259
- if (pcm != null) {
260
- final wavBytes = _createWav(pcm, sampleRate);
261
- try {
262
- await _player.play(BytesSource(wavBytes));
263
- } catch (e) {
264
- Log.error('AudioPlayer play error: $e');
265
- }
266
- }
411
+ await _playNextChunk();
412
} catch (e, st) {
413
if (_isPlayerDisposed) return;
414
Log.error('TTS speak error: $e\n$st');
@@ -276,10 +421,70 @@ class TtsNotifier extends Notifier<TtsState> {
421
}
422
}
423
424
+ Future<void> _playNextChunk() async {
425
+ if (_isStopping || _currentChunkIndex >= _chunks.length) return;
426
+
427
+ // Start synthesizing more chunks
428
+ _enqueueNextChunks();
429
+
430
+ // Check if current chunk is synthesized
431
+ if (!_synthesizedChunks.containsKey(_currentChunkIndex)) {
432
+ Log.debug('Waiting for chunk $_currentChunkIndex to be synthesized...');
433
+ return;
434
+ }
435
+
436
+ final wavBytes = _synthesizedChunks[_currentChunkIndex]!;
437
+ _synthesizedChunks.remove(_currentChunkIndex); // Clear memory
438
+
439
+ try {
440
+ Log.debug(
441
+ 'Playing chunk ${_currentChunkIndex + 1}/${_chunks.length}: "${_chunks[_currentChunkIndex]}"',
442
+ );
443
+
444
+ await _player.stop();
445
+ await _player.setSource(BytesSource(wavBytes));
446
+ await _player.resume();
447
+ } catch (e) {
448
+ Log.error('Error playing chunk $_currentChunkIndex: $e');
449
+ _onChunkFinished();
450
+ }
451
+ }
452
+
453
+ void _enqueueNextChunks() {
454
+ if (_workerSendPort == null || _currentEngine == null) return;
455
+
456
+ final settings = ref.read(settingsProvider);
457
+ final availableVoices = voicesForEngine(_currentEngine!);
458
+ final voiceId =
459
+ _currentVoice ??
460
+ settings.ttsVoiceId ??
461
+ (availableVoices.isNotEmpty ? availableVoices.first.id : 'Luna');
462
+
463
+ while (_lastEnqueuedChunk < _chunks.length - 1 &&
464
+ _lastEnqueuedChunk < _currentChunkIndex + _lookAheadCount) {
465
+ _lastEnqueuedChunk++;
466
+ final chunkIndex = _lastEnqueuedChunk;
467
+ final text = _chunks[chunkIndex];
468
+
469
+ Log.debug('Enqueuing synthesis for chunk $chunkIndex');
470
+ _workerSendPort!.send(
471
+ TtsSynthesizeRequest(
472
+ text: text,
473
+ voiceId: voiceId,
474
+ speed: settings.ttsSpeed,
475
+ chunkIndex: chunkIndex,
476
+ sessionId: _currentSessionId,
477
+ ),
478
+ );
479
+ }
480
+ }
481
+
482
Future<void> stop() async {
483
+ _isStopping = true;
484
if (_isPlayerDisposed) return;
485
try {
486
await _player.stop();
487
+ // Don't release here, as it makes restarting slower and can cause state issues
488
} catch (_) {}
489
await _flutterTts?.stop();
490
state = state.copyWith(
@@ -289,58 +494,8 @@ class TtsNotifier extends Notifier<TtsState> {
494
);
495
}
496
292
- Uint8List _createWav(Float32List samples, int sampleRate) {
293
- // Simple WAV header creation
294
- final int byteCount = samples.length * 2;
295
- final int totalSize = 36 + byteCount;
296
- final Uint8List wav = Uint8List(44 + byteCount);
297
- final ByteData data = ByteData.view(wav.buffer);
298
-
299
- data.setUint8(0, 0x52); // R
300
- data.setUint8(1, 0x49); // I
301
- data.setUint8(2, 0x46); // F
302
- data.setUint8(3, 0x46); // F
303
- data.setUint32(4, totalSize, Endian.little);
304
- data.setUint8(8, 0x57); // W
305
- data.setUint8(9, 0x41); // A
306
- data.setUint8(10, 0x56); // V
307
- data.setUint8(11, 0x45); // E
308
- data.setUint8(12, 0x66); // f
309
- data.setUint8(13, 0x6d); // m
310
- data.setUint8(14, 0x74); // t
311
- data.setUint8(15, 0x20); // space
312
- data.setUint32(16, 16, Endian.little);
313
- data.setUint16(20, 1, Endian.little); // PCM
314
- data.setUint16(22, 1, Endian.little); // Mono
315
- data.setUint32(24, sampleRate, Endian.little);
316
- data.setUint32(28, sampleRate * 2, Endian.little);
317
- data.setUint16(32, 2, Endian.little);
318
- data.setUint16(34, 16, Endian.little); // 16-bit
319
- data.setUint8(36, 0x64); // d
320
- data.setUint8(37, 0x61); // a
321
- data.setUint8(38, 0x74); // t
322
- data.setUint8(39, 0x61); // a
323
- data.setUint32(40, byteCount, Endian.little);
324
-
325
- for (int i = 0; i < samples.length; i++) {
326
- final int sample = (samples[i] * 32767).clamp(-32768, 32767).toInt();
327
- data.setInt16(44 + (i * 2), sample, Endian.little);
328
- }
329
-
330
- return wav;
331
- }
332
-
497
Future<void> previewVoice(Voice voice) async {
334
- final settings = ref.read(settingsProvider);
335
- final previousEngine = settings.ttsEngine;
336
- final previousVoiceId = settings.ttsVoiceId;
337
- ref.read(settingsProvider.notifier).setTtsEngine(voice.engine);
338
- ref.read(settingsProvider.notifier).setTtsVoiceId(voice.id);
339
- await speak(ttsPreviewSample);
340
- ref.read(settingsProvider.notifier).setTtsEngine(previousEngine);
341
- if (previousVoiceId != null) {
342
- ref.read(settingsProvider.notifier).setTtsVoiceId(previousVoiceId);
343
- }
498
+ await speak(ttsPreviewSample, engineId: voice.engine, voiceId: voice.id);
499
}
500
}
501
lib/features/tts/providers/tts_worker.dart
new
+200
@@ -0,0 +1,200 @@
1
+import 'dart:isolate';
2
+import 'dart:typed_data';
3
+import 'package:meomeo/meomeo.dart';
4
+import '../../../core/logger/app_logger.dart';
5
+import '../../../core/models/enums.dart';
6
+
7
+sealed class TtsWorkerMessage {}
8
+
9
+class TtsInitRequest extends TtsWorkerMessage {
10
+ final EngineId engineId;
11
+ final String modelPath;
12
+ final String voicesPath;
13
+ final String espeakPath;
14
+
15
+ TtsInitRequest({
16
+ required this.engineId,
17
+ required this.modelPath,
18
+ required this.voicesPath,
19
+ required this.espeakPath,
20
+ });
21
+}
22
+
23
+class TtsSynthesizeRequest extends TtsWorkerMessage {
24
+ final String text;
25
+ final String voiceId;
26
+ final double speed;
27
+ final int chunkIndex;
28
+ final int sessionId;
29
+
30
+ TtsSynthesizeRequest({
31
+ required this.text,
32
+ required this.voiceId,
33
+ required this.speed,
34
+ required this.chunkIndex,
35
+ required this.sessionId,
36
+ });
37
+}
38
+
39
+class TtsStopRequest extends TtsWorkerMessage {}
40
+
41
+class TtsWorkerResponse {
42
+ final int? chunkIndex;
43
+ final int? sessionId;
44
+ final Uint8List? wavBytes;
45
+ final String? error;
46
+ final bool isInitResponse;
47
+
48
+ TtsWorkerResponse({
49
+ this.chunkIndex,
50
+ this.sessionId,
51
+ this.wavBytes,
52
+ this.error,
53
+ this.isInitResponse = false,
54
+ });
55
+}
56
+
57
+void ttsWorkerEntry(SendPort mainSendPort) {
58
+ final receivePort = ReceivePort();
59
+ mainSendPort.send(receivePort.sendPort);
60
+
61
+ MeoKitten? kitten;
62
+ MeoKokoro? kokoro;
63
+ MeoPiper? piper;
64
+ EngineId? activeEngineId;
65
+
66
+ receivePort.listen((message) async {
67
+ try {
68
+ if (message is TtsInitRequest) {
69
+ // Dispose old engines
70
+ try {
71
+ kitten?.dispose();
72
+ kokoro?.dispose();
73
+ piper?.dispose();
74
+ } catch (_) {}
75
+ kitten = null;
76
+ kokoro = null;
77
+ piper = null;
78
+
79
+ activeEngineId = message.engineId;
80
+ switch (message.engineId) {
81
+ case EngineId.kitten:
82
+ kitten = MeoKitten(
83
+ model: message.modelPath,
84
+ voices: message.voicesPath,
85
+ espeakData: message.espeakPath,
86
+ );
87
+ break;
88
+ case EngineId.kokoro:
89
+ kokoro = MeoKokoro(
90
+ model: message.modelPath,
91
+ voices: message.voicesPath,
92
+ espeakData: message.espeakPath,
93
+ );
94
+ break;
95
+ case EngineId.piper:
96
+ piper = MeoPiper.dir(
97
+ path: message.modelPath,
98
+ espeakData: message.espeakPath,
99
+ );
100
+ break;
101
+ default:
102
+ Log.warning('Unknown engine ID: ${message.engineId}');
103
+ break;
104
+ }
105
+ Log.info('Worker initialized engine: ${message.engineId}');
106
+ mainSendPort.send(TtsWorkerResponse(isInitResponse: true));
107
+ } else if (message is TtsSynthesizeRequest) {
108
+ if (activeEngineId == null) {
109
+ mainSendPort.send(TtsWorkerResponse(
110
+ chunkIndex: message.chunkIndex,
111
+ sessionId: message.sessionId,
112
+ error: 'No engine initialized in worker',
113
+ ));
114
+ return;
115
+ }
116
+
117
+ final speaker = Speaker(voice: message.voiceId, speed: message.speed);
118
+ Float32List? pcm;
119
+ int sampleRate = 22050;
120
+
121
+ if (activeEngineId == EngineId.kitten) {
122
+ Log.debug('Worker synthesis with Kitten: "${message.text}"');
123
+ pcm = await kitten?.speak(message.text, speaker: speaker);
124
+ sampleRate = 24000;
125
+ } else if (activeEngineId == EngineId.kokoro) {
126
+ Log.debug('Worker synthesis with Kokoro: "${message.text}"');
127
+ pcm = await kokoro?.speak(message.text, speaker: speaker);
128
+ sampleRate = 24000;
129
+ } else if (activeEngineId == EngineId.piper) {
130
+ Log.debug('Worker synthesis with Piper: "${message.text}"');
131
+ pcm = await piper?.speak(message.text, speaker: speaker);
132
+ sampleRate = 22050;
133
+ }
134
+
135
+ if (pcm != null && pcm.isNotEmpty) {
136
+ Log.debug('Worker synthesis success: ${pcm.length} samples');
137
+ final wavBytes = _createWav(pcm, sampleRate);
138
+ mainSendPort.send(TtsWorkerResponse(
139
+ chunkIndex: message.chunkIndex,
140
+ sessionId: message.sessionId,
141
+ wavBytes: wavBytes,
142
+ ));
143
+ } else {
144
+ final err = pcm == null ? 'Synthesis returned null' : 'Empty PCM generated';
145
+ Log.error('Worker synthesis failed for chunk ${message.chunkIndex}: $err');
146
+ mainSendPort.send(TtsWorkerResponse(
147
+ chunkIndex: message.chunkIndex,
148
+ sessionId: message.sessionId,
149
+ error: err,
150
+ ));
151
+ }
152
+ } else if (message is TtsStopRequest) {
153
+ // Handled by main thread by clearing its own queue,
154
+ // but we could use this to cancel current synthesis if meomeo supported it.
155
+ }
156
+ } catch (e) {
157
+ mainSendPort.send(TtsWorkerResponse(error: e.toString()));
158
+ }
159
+ });
160
+}
161
+
162
+Uint8List _createWav(Float32List samples, int sampleRate) {
163
+ final int byteCount = samples.length * 2;
164
+ final int totalSize = 36 + byteCount;
165
+ final Uint8List wav = Uint8List(44 + byteCount);
166
+ final ByteData data = ByteData.view(wav.buffer);
167
+
168
+ data.setUint8(0, 0x52); // R
169
+ data.setUint8(1, 0x49); // I
170
+ data.setUint8(2, 0x46); // F
171
+ data.setUint8(3, 0x46); // F
172
+ data.setUint32(4, totalSize, Endian.little);
173
+ data.setUint8(8, 0x57); // W
174
+ data.setUint8(9, 0x41); // A
175
+ data.setUint8(10, 0x56); // V
176
+ data.setUint8(11, 0x45); // E
177
+ data.setUint8(12, 0x66); // f
178
+ data.setUint8(13, 0x6d); // m
179
+ data.setUint8(14, 0x74); // t
180
+ data.setUint8(15, 0x20); // space
181
+ data.setUint32(16, 16, Endian.little);
182
+ data.setUint16(20, 1, Endian.little); // PCM
183
+ data.setUint16(22, 1, Endian.little); // Mono
184
+ data.setUint32(24, sampleRate, Endian.little);
185
+ data.setUint32(28, sampleRate * 2, Endian.little);
186
+ data.setUint16(32, 2, Endian.little);
187
+ data.setUint16(34, 16, Endian.little); // 16-bit
188
+ data.setUint8(36, 0x64); // d
189
+ data.setUint8(37, 0x61); // a
190
+ data.setUint8(38, 0x74); // t
191
+ data.setUint8(39, 0x61); // a
192
+ data.setUint32(40, byteCount, Endian.little);
193
+
194
+ for (int i = 0; i < samples.length; i++) {
195
+ final int sample = (samples[i] * 32767).clamp(-32768, 32767).toInt();
196
+ data.setInt16(44 + (i * 2), sample, Endian.little);
197
+ }
198
+
199
+ return wav;
200
+}
lib/features/tts/views/components/tts_player_bar.dart
new
+173
@@ -0,0 +1,173 @@
1
+import 'package:flutter/material.dart';
2
+import 'package:flutter_riverpod/flutter_riverpod.dart';
3
+import '../../providers/tts_providers.dart' as tts;
4
+
5
+/// Mini TTS player bar shown when speech is playing or
6
+/// initializing. Displays a waveform icon, a content preview, and a stop button.
7
+class TtsPlayerBar extends ConsumerWidget {
8
+ final EdgeInsetsGeometry margin;
9
+
10
+ const TtsPlayerBar({
11
+ super.key,
12
+ this.margin = const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
13
+ });
14
+
15
+ @override
16
+ Widget build(BuildContext context, WidgetRef ref) {
17
+ final ttsState = ref.watch(tts.ttsProvider);
18
+ ref.listen(tts.ttsProvider, (prev, next) {
19
+ if (next.error != null && prev?.error != next.error) {
20
+ ScaffoldMessenger.of(context).showSnackBar(
21
+ SnackBar(
22
+ content: Text(next.error!),
23
+ backgroundColor: Colors.red,
24
+ duration: const Duration(seconds: 3),
25
+ ),
26
+ );
27
+ }
28
+ });
29
+ final theme = Theme.of(context);
30
+ final isDark = theme.brightness == Brightness.dark;
31
+
32
+ if (!ttsState.isSpeaking && !ttsState.isInitializing) {
33
+ return const SizedBox.shrink();
34
+ }
35
+
36
+ final preview = _truncateContent(ttsState.playingContent);
37
+
38
+ return AnimatedSize(
39
+ duration: const Duration(milliseconds: 200),
40
+ curve: Curves.easeInOut,
41
+ child: Container(
42
+ width: double.infinity,
43
+ margin: margin,
44
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
45
+ decoration: BoxDecoration(
46
+ color: isDark ? const Color(0xFF1E293B) : const Color(0xFFEFF6FF),
47
+ borderRadius: BorderRadius.circular(10),
48
+ border: Border.all(
49
+ color: isDark ? const Color(0xFF334155) : const Color(0xFFBFDBFE),
50
+ ),
51
+ ),
52
+ child: Row(
53
+ children: [
54
+ _AnimatedWaveIndicator(isActive: ttsState.isSpeaking),
55
+ const SizedBox(width: 10),
56
+ Expanded(
57
+ child: Column(
58
+ crossAxisAlignment: CrossAxisAlignment.start,
59
+ mainAxisSize: MainAxisSize.min,
60
+ children: [
61
+ Text(
62
+ preview ??
63
+ (ttsState.isInitializing ? 'Loading...' : 'Playing'),
64
+ style: TextStyle(
65
+ fontSize: 12,
66
+ fontWeight: FontWeight.w500,
67
+ color: isDark ? Colors.white70 : Colors.black87,
68
+ ),
69
+ maxLines: 1,
70
+ overflow: TextOverflow.ellipsis,
71
+ ),
72
+ if (ttsState.isInitializing)
73
+ Text(
74
+ 'Initializing TTS...',
75
+ style: TextStyle(
76
+ fontSize: 10,
77
+ color: isDark ? Colors.white38 : Colors.black45,
78
+ ),
79
+ ),
80
+ ],
81
+ ),
82
+ ),
83
+ const SizedBox(width: 8),
84
+ GestureDetector(
85
+ onTap: () => ref.read(tts.ttsProvider.notifier).stop(),
86
+ child: Tooltip(
87
+ message: 'Stop',
88
+ child: Container(
89
+ padding: const EdgeInsets.all(4),
90
+ decoration: BoxDecoration(
91
+ color: Colors.red.withValues(alpha: 0.1),
92
+ borderRadius: BorderRadius.circular(6),
93
+ ),
94
+ child: Icon(
95
+ Icons.stop_circle,
96
+ size: 20,
97
+ color: isDark ? Colors.red[300] : Colors.red[600],
98
+ ),
99
+ ),
100
+ ),
101
+ ),
102
+ ],
103
+ ),
104
+ ),
105
+ );
106
+ }
107
+
108
+ String? _truncateContent(String? content) {
109
+ if (content == null || content.isEmpty) return null;
110
+ if (content.length <= 50) return content;
111
+ return '${content.substring(0, 47)}...';
112
+ }
113
+}
114
+
115
+/// An animated audio wave indicator with a subtle bounce effect.
116
+class _AnimatedWaveIndicator extends StatefulWidget {
117
+ final bool isActive;
118
+ const _AnimatedWaveIndicator({required this.isActive});
119
+
120
+ @override
121
+ State<_AnimatedWaveIndicator> createState() => _AnimatedWaveIndicatorState();
122
+}
123
+
124
+class _AnimatedWaveIndicatorState extends State<_AnimatedWaveIndicator>
125
+ with SingleTickerProviderStateMixin {
126
+ late AnimationController _controller;
127
+
128
+ @override
129
+ void initState() {
130
+ super.initState();
131
+ _controller = AnimationController(
132
+ duration: const Duration(milliseconds: 800),
133
+ vsync: this,
134
+ );
135
+ if (widget.isActive) {
136
+ _controller.repeat(reverse: true);
137
+ }
138
+ }
139
+
140
+ @override
141
+ void didUpdateWidget(_AnimatedWaveIndicator oldWidget) {
142
+ super.didUpdateWidget(oldWidget);
143
+ if (widget.isActive && !_controller.isAnimating) {
144
+ _controller.repeat(reverse: true);
145
+ } else if (!widget.isActive && _controller.isAnimating) {
146
+ _controller.stop();
147
+ }
148
+ }
149
+
150
+ @override
151
+ void dispose() {
152
+ _controller.dispose();
153
+ super.dispose();
154
+ }
155
+
156
+ @override
157
+ Widget build(BuildContext context) {
158
+ return AnimatedBuilder(
159
+ animation: _controller,
160
+ builder: (context, child) {
161
+ final scale = widget.isActive ? 1.0 + _controller.value * 0.15 : 1.0;
162
+ return Transform.scale(
163
+ scale: scale,
164
+ child: Icon(
165
+ Icons.graphic_eq,
166
+ size: 18,
167
+ color: widget.isActive ? const Color(0xFF3B82F6) : Colors.grey,
168
+ ),
169
+ );
170
+ },
171
+ );
172
+ }
173
+}
lib/features/tts/views/tts_model_manager_screen.dart
+396
-109
@@ -10,6 +10,7 @@ import '../../settings/data/models/app_settings.dart';
10
import '../../sidebar/sidebar_widget.dart';
11
import '../data/kitten_tts_model.dart';
12
import '../data/kokoro_tts_model.dart';
13
+import '../data/piper_tts_model.dart';
14
import '../providers/tts_model_providers.dart';
15
import '../providers/tts_providers.dart' as tts;
16
@@ -24,7 +25,8 @@ final installedEnginesProvider = FutureProvider<Set<EngineId>>((ref) async {
25
if (downloadedVariants.isNotEmpty) {
26
result.add(EngineId.kitten);
27
}
27
- final downloadedKokoroVariants = await kokoroDownloader.getDownloadedVariants();
28
+ final downloadedKokoroVariants = await kokoroDownloader
29
+ .getDownloadedVariants();
30
if (downloadedKokoroVariants.isNotEmpty) {
31
result.add(EngineId.kokoro);
32
}
@@ -150,7 +152,7 @@ class _KittenEngineCard extends ConsumerWidget {
152
: isDownloading
153
? 'Downloading...'
154
: 'Not installed',
153
- trailing: _buildAction(ref, isInstalled, isDownloading, model),
155
+ trailing: _buildAction(context, ref, isInstalled, isDownloading, model),
156
child: Column(
157
crossAxisAlignment: CrossAxisAlignment.start,
158
children: [
@@ -179,6 +181,7 @@ class _KittenEngineCard extends ConsumerWidget {
181
}
182
183
Widget _buildAction(
184
+ BuildContext context,
185
WidgetRef ref,
186
bool isInstalled,
187
bool isDownloading,
@@ -193,12 +196,24 @@ class _KittenEngineCard extends ConsumerWidget {
196
);
197
}
198
if (isInstalled) {
196
- if (isSelected) return const SizedBox.shrink();
197
- return ShadButton.outline(
198
- size: ShadButtonSize.sm,
199
- onPressed: () =>
200
- ref.read(settingsProvider.notifier).setTtsEngine(EngineId.kitten),
201
- child: const Text('Select'),
199
+ return Row(
200
+ mainAxisSize: MainAxisSize.min,
201
+ children: [
202
+ if (!isSelected)
203
+ ShadButton.outline(
204
+ size: ShadButtonSize.sm,
205
+ onPressed: () => ref
206
+ .read(settingsProvider.notifier)
207
+ .setTtsEngine(EngineId.kitten),
208
+ child: const Text('Select'),
209
+ ),
210
+ const SizedBox(width: 8),
211
+ ShadIconButton.ghost(
212
+ padding: EdgeInsets.zero,
213
+ icon: const Icon(Icons.delete_outline, color: Colors.red, size: 20),
214
+ onPressed: () => _confirmDelete(context, ref, model),
215
+ ),
216
+ ],
217
);
218
}
219
return ShadButton.outline(
@@ -208,6 +223,41 @@ class _KittenEngineCard extends ConsumerWidget {
223
);
224
}
225
226
+ void _confirmDelete(
227
+ BuildContext context,
228
+ WidgetRef ref,
229
+ KittenTtsModel model,
230
+ ) {
231
+ showShadDialog(
232
+ context: context,
233
+ builder: (context) => ShadDialog(
234
+ title: const Text('Delete Model'),
235
+ description: Text(
236
+ 'Are you sure you want to delete ${model.displayName}? This will free up approximately ${_formatSize(model.totalSizeBytes)} of space.',
237
+ ),
238
+ actions: [
239
+ ShadButton.outline(
240
+ child: const Text('Cancel'),
241
+ onPressed: () => Navigator.of(context).pop(),
242
+ ),
243
+ ShadButton.destructive(
244
+ child: const Text('Delete'),
245
+ onPressed: () {
246
+ ref
247
+ .read(ttsDownloadProgressProvider.notifier)
248
+ .deleteVariant(model.variant);
249
+ Navigator.of(context).pop();
250
+ },
251
+ ),
252
+ ],
253
+ child: const Padding(
254
+ padding: EdgeInsets.symmetric(vertical: 20),
255
+ child: Text('You can download this model again later if needed.'),
256
+ ),
257
+ ),
258
+ );
259
+ }
260
+
261
String _formatSize(int bytes) {
262
final mb = bytes / (1024 * 1024);
263
return '${mb.toStringAsFixed(0)} MB';
@@ -289,7 +339,7 @@ class _KokoroEngineCard extends ConsumerWidget {
339
: isDownloading
340
? 'Downloading...'
341
: 'Not installed',
292
- trailing: _buildAction(ref, isInstalled, isDownloading, model),
342
+ trailing: _buildAction(context, ref, isInstalled, isDownloading, model),
343
child: Column(
344
crossAxisAlignment: CrossAxisAlignment.start,
345
children: [
@@ -299,21 +349,15 @@ class _KokoroEngineCard extends ConsumerWidget {
349
'Kokoro 82M parameter model with 22 expressive voices.\n'
350
'Requires ${_formatSize(model.totalSizeBytes)} download.',
351
style: Theme.of(context).textTheme.bodySmall?.copyWith(
302
- color: Theme.of(context)
303
- .colorScheme
304
- .onSurface
305
- .withValues(alpha: 0.6),
306
- ),
352
+ color: Theme.of(
353
+ context,
354
+ ).colorScheme.onSurface.withValues(alpha: 0.6),
355
+ ),
356
),
357
),
358
if (isDownloading) ...[
359
const SizedBox(height: 12),
311
- _buildVariantDownloadProgress(
312
- context,
313
- ref,
314
- model,
315
- variantProgress,
316
- ),
360
+ _buildVariantDownloadProgress(context, ref, model, variantProgress),
361
],
362
if (isInstalled) ...[
363
_VoiceChips(voices: kokoroVoices, engine: EngineId.kokoro),
@@ -324,6 +368,7 @@ class _KokoroEngineCard extends ConsumerWidget {
368
}
369
370
Widget _buildAction(
371
+ BuildContext context,
372
WidgetRef ref,
373
bool isInstalled,
374
bool isDownloading,
@@ -338,12 +383,24 @@ class _KokoroEngineCard extends ConsumerWidget {
383
);
384
}
385
if (isInstalled) {
341
- if (isSelected) return const SizedBox.shrink();
342
- return ShadButton.outline(
343
- size: ShadButtonSize.sm,
344
- onPressed: () =>
345
- ref.read(settingsProvider.notifier).setTtsEngine(EngineId.kokoro),
346
- child: const Text('Select'),
386
+ return Row(
387
+ mainAxisSize: MainAxisSize.min,
388
+ children: [
389
+ if (!isSelected)
390
+ ShadButton.outline(
391
+ size: ShadButtonSize.sm,
392
+ onPressed: () => ref
393
+ .read(settingsProvider.notifier)
394
+ .setTtsEngine(EngineId.kokoro),
395
+ child: const Text('Select'),
396
+ ),
397
+ const SizedBox(width: 8),
398
+ ShadIconButton.ghost(
399
+ padding: EdgeInsets.zero,
400
+ icon: const Icon(Icons.delete_outline, color: Colors.red, size: 20),
401
+ onPressed: () => _confirmDelete(context, ref, model),
402
+ ),
403
+ ],
404
);
405
}
406
return ShadButton.outline(
@@ -353,6 +410,41 @@ class _KokoroEngineCard extends ConsumerWidget {
410
);
411
}
412
413
+ void _confirmDelete(
414
+ BuildContext context,
415
+ WidgetRef ref,
416
+ KokoroTtsModel model,
417
+ ) {
418
+ showShadDialog(
419
+ context: context,
420
+ builder: (context) => ShadDialog(
421
+ title: const Text('Delete Model'),
422
+ description: Text(
423
+ 'Are you sure you want to delete ${model.displayName}? This will free up approximately ${_formatSize(model.totalSizeBytes)} of space.',
424
+ ),
425
+ actions: [
426
+ ShadButton.outline(
427
+ child: const Text('Cancel'),
428
+ onPressed: () => Navigator.of(context).pop(),
429
+ ),
430
+ ShadButton.destructive(
431
+ child: const Text('Delete'),
432
+ onPressed: () {
433
+ ref
434
+ .read(kokoroTtsDownloadProgressProvider.notifier)
435
+ .deleteVariant(model.variant);
436
+ Navigator.of(context).pop();
437
+ },
438
+ ),
439
+ ],
440
+ child: const Padding(
441
+ padding: EdgeInsets.symmetric(vertical: 20),
442
+ child: Text('You can download this model again later if needed.'),
443
+ ),
444
+ ),
445
+ );
446
+ }
447
+
448
String _formatSize(int bytes) {
449
final mb = bytes / (1024 * 1024);
450
return '${mb.toStringAsFixed(0)} MB';
@@ -405,51 +497,181 @@ class _PiperEngineCard extends ConsumerWidget {
497
498
@override
499
Widget build(BuildContext context, WidgetRef ref) {
408
- final meta = EngineMeta(
409
- name: 'Piper TTS',
410
- tagline: 'Open-source neural voices',
411
- sizeMb: 50,
412
- ramMb: 100,
413
- voiceCount: 100,
414
- accentColor: 0xFF4CAF50,
415
- );
416
- final installedAsync = ref.watch(installedEnginesProvider);
417
- final isInstalled = installedAsync.when(
418
- data: (set) => set.contains(EngineId.piper),
500
+ final meta = EngineMeta.piper;
501
+ final downloadedAsync = ref.watch(downloadedPiperTtsVariantsProvider);
502
+ final downloadProgress = ref.watch(piperTtsDownloadProgressProvider);
503
+
504
+ // For now we just download all variants (or a default one).
505
+ // Let's just track the first one for simplicity of the UI install button,
506
+ // or we can download the first variant as the "install" action.
507
+ final variant = PiperTtsModelVariant.enUsLessacMedium;
508
+ final variantProgress = downloadProgress[variant];
509
+
510
+ final isInstalled = downloadedAsync.when(
511
+ data: (set) => set.contains(variant),
512
loading: () => false,
513
error: (_, _) => false,
514
);
515
+ final notifier = ref.read(piperTtsDownloadProgressProvider.notifier);
516
+ final isDownloading = notifier.isDownloading(variant);
517
518
return _EngineCard(
519
isSelected: isSelected,
520
meta: meta,
521
engine: EngineId.piper,
522
installed: isInstalled,
428
- statusText: isInstalled ? 'Installed' : 'Not installed',
429
- trailing: isInstalled && !isSelected
430
- ? ShadButton.outline(
523
+ statusText: isInstalled
524
+ ? 'Installed'
525
+ : isDownloading
526
+ ? 'Downloading...'
527
+ : 'Not installed',
528
+ trailing: _buildAction(context, ref, isInstalled, isDownloading, variant),
529
+ child: Column(
530
+ crossAxisAlignment: CrossAxisAlignment.start,
531
+ children: [
532
+ Padding(
533
+ padding: const EdgeInsets.only(top: 12),
534
+ child: Text(
535
+ 'One ONNX model per voice. 30+ languages, hundreds of voices.\n'
536
+ 'Requires ~${meta.sizeMb} MB download for the default voice.',
537
+ style: Theme.of(context).textTheme.bodySmall?.copyWith(
538
+ color: Theme.of(
539
+ context,
540
+ ).colorScheme.onSurface.withValues(alpha: 0.6),
541
+ ),
542
+ ),
543
+ ),
544
+ if (isDownloading) ...[
545
+ const SizedBox(height: 12),
546
+ _buildVariantDownloadProgress(
547
+ context,
548
+ ref,
549
+ variant,
550
+ variantProgress,
551
+ ),
552
+ ],
553
+ if (isInstalled) ...[
554
+ _VoiceChips(voices: piperVoices, engine: EngineId.piper),
555
+ ],
556
+ ],
557
+ ),
558
+ );
559
+ }
560
+
561
+ Widget _buildAction(
562
+ BuildContext context,
563
+ WidgetRef ref,
564
+ bool isInstalled,
565
+ bool isDownloading,
566
+ PiperTtsModelVariant variant,
567
+ ) {
568
+ final notifier = ref.read(piperTtsDownloadProgressProvider.notifier);
569
+ if (isDownloading) {
570
+ return ShadButton.outline(
571
+ size: ShadButtonSize.sm,
572
+ onPressed: () => notifier.cancelDownload(variant),
573
+ child: const Text('Cancel'),
574
+ );
575
+ }
576
+ if (isInstalled) {
577
+ return Row(
578
+ mainAxisSize: MainAxisSize.min,
579
+ children: [
580
+ if (!isSelected)
581
+ ShadButton.outline(
582
size: ShadButtonSize.sm,
583
onPressed: () => ref
584
.read(settingsProvider.notifier)
585
.setTtsEngine(EngineId.piper),
586
child: const Text('Select'),
436
- )
437
- : null,
438
- child: Padding(
439
- padding: const EdgeInsets.only(top: 12),
440
- child: Text(
441
- 'One ONNX model per voice. 30+ languages, hundreds of voices.\n'
442
- 'Place model files in the tts_models/piper directory.',
443
- style: Theme.of(context).textTheme.bodySmall?.copyWith(
444
- color: Theme.of(context)
445
- .colorScheme
446
- .onSurface
447
- .withValues(alpha: 0.6),
448
- ),
587
+ ),
588
+ const SizedBox(width: 8),
589
+ ShadIconButton.ghost(
590
+ padding: EdgeInsets.zero,
591
+ icon: const Icon(Icons.delete_outline, color: Colors.red, size: 20),
592
+ onPressed: () => _confirmDelete(context, ref, variant),
593
+ ),
594
+ ],
595
+ );
596
+ }
597
+ return ShadButton.outline(
598
+ size: ShadButtonSize.sm,
599
+ onPressed: () => notifier.startDownload(variant),
600
+ child: const Text('Install'),
601
+ );
602
+ }
603
+
604
+ void _confirmDelete(
605
+ BuildContext context,
606
+ WidgetRef ref,
607
+ PiperTtsModelVariant variant,
608
+ ) {
609
+ showShadDialog(
610
+ context: context,
611
+ builder: (context) => ShadDialog(
612
+ title: const Text('Delete Model'),
613
+ description: Text(
614
+ 'Are you sure you want to delete ${variant.displayName}? This will free up storage space on your device.',
615
+ ),
616
+ actions: [
617
+ ShadButton.outline(
618
+ child: const Text('Cancel'),
619
+ onPressed: () => Navigator.of(context).pop(),
620
+ ),
621
+ ShadButton.destructive(
622
+ child: const Text('Delete'),
623
+ onPressed: () {
624
+ ref
625
+ .read(piperTtsDownloadProgressProvider.notifier)
626
+ .deleteVariant(variant);
627
+ Navigator.of(context).pop();
628
+ },
629
+ ),
630
+ ],
631
+ child: const Padding(
632
+ padding: EdgeInsets.symmetric(vertical: 20),
633
+ child: Text('You can download this model again later if needed.'),
634
),
635
),
636
);
637
}
638
+
639
+ Widget _buildVariantDownloadProgress(
640
+ BuildContext context,
641
+ WidgetRef ref,
642
+ PiperTtsModelVariant variant,
643
+ Map<String, PiperTtsFileProgress>? progress,
644
+ ) {
645
+ final fraction = ref
646
+ .read(piperTtsDownloadProgressProvider.notifier)
647
+ .getOverallFraction(variant);
648
+ return Column(
649
+ children: [
650
+ LinearProgressIndicator(value: fraction),
651
+ const SizedBox(height: 4),
652
+ if (progress != null)
653
+ ...progress.entries.map(
654
+ (e) => Padding(
655
+ padding: const EdgeInsets.only(bottom: 2),
656
+ child: Row(
657
+ children: [
658
+ Expanded(
659
+ child: Text(
660
+ e.key,
661
+ style: Theme.of(context).textTheme.labelSmall,
662
+ ),
663
+ ),
664
+ Text(
665
+ '${(e.value.fraction * 100).toStringAsFixed(0)}%',
666
+ style: Theme.of(context).textTheme.labelSmall,
667
+ ),
668
+ ],
669
+ ),
670
+ ),
671
+ ),
672
+ ],
673
+ );
674
+ }
675
}
676
677
// ── Shared Widgets ──
@@ -580,7 +802,6 @@ class _EngineCard extends StatelessWidget {
802
),
803
),
804
),
583
- const Spacer(),
805
?trailing,
806
],
807
),
@@ -628,14 +849,20 @@ class _VoiceChipsState extends ConsumerState<_VoiceChips> {
849
),
850
),
851
const SizedBox(height: 6),
631
- Wrap(
632
- spacing: 6,
633
- runSpacing: 6,
634
- children: females
635
- .map((v) => _voiceChip(v, theme, accent, selectedVoiceId))
636
- .toList(),
852
+ SingleChildScrollView(
853
+ scrollDirection: Axis.horizontal,
854
+ child: Row(
855
+ children: females
856
+ .map(
857
+ (v) => Padding(
858
+ padding: const EdgeInsets.only(right: 8),
859
+ child: _voiceChip(v, theme, accent, selectedVoiceId),
860
+ ),
861
+ )
862
+ .toList(),
863
+ ),
864
),
638
- const SizedBox(height: 8),
865
+ const SizedBox(height: 12),
866
],
867
if (males.isNotEmpty) ...[
868
Text(
@@ -645,12 +872,18 @@ class _VoiceChipsState extends ConsumerState<_VoiceChips> {
872
),
873
),
874
const SizedBox(height: 6),
648
- Wrap(
649
- spacing: 6,
650
- runSpacing: 6,
651
- children: males
652
- .map((v) => _voiceChip(v, theme, accent, selectedVoiceId))
653
- .toList(),
875
+ SingleChildScrollView(
876
+ scrollDirection: Axis.horizontal,
877
+ child: Row(
878
+ children: males
879
+ .map(
880
+ (v) => Padding(
881
+ padding: const EdgeInsets.only(right: 8),
882
+ child: _voiceChip(v, theme, accent, selectedVoiceId),
883
+ ),
884
+ )
885
+ .toList(),
886
+ ),
887
),
888
],
889
],
@@ -665,54 +898,108 @@ class _VoiceChipsState extends ConsumerState<_VoiceChips> {
898
String? selectedVoiceId,
899
) {
900
final isPlaying = _playingVoice == voice;
668
- final isSelected = voice.id == selectedVoiceId && !isPlaying;
669
-
670
- return ActionChip(
671
- avatar: Icon(
672
- isSelected
673
- ? Icons.check_circle
674
- : isPlaying
675
- ? Icons.stop
676
- : Icons.play_arrow,
677
- size: 16,
678
- color: isSelected
679
- ? Colors.green
680
- : isPlaying
681
- ? accent
682
- : theme.colorScheme.onSurface.withValues(alpha: 0.5),
683
- ),
684
- label: Text(voice.name),
685
- labelStyle: theme.textTheme.labelSmall?.copyWith(
686
- color: isSelected
687
- ? Colors.green
688
- : isPlaying
689
- ? accent
690
- : theme.colorScheme.onSurface,
691
- fontWeight: isSelected || isPlaying
692
- ? FontWeight.bold
693
- : FontWeight.normal,
694
- ),
695
- backgroundColor: isSelected
696
- ? Colors.green.withValues(alpha: 0.1)
697
- : isPlaying
698
- ? accent.withValues(alpha: 0.1)
699
- : Colors.transparent,
700
- side: BorderSide(
701
- color: isSelected
702
- ? Colors.green.withValues(alpha: 0.5)
703
- : isPlaying
704
- ? accent.withValues(alpha: 0.3)
705
- : theme.colorScheme.outline.withValues(alpha: 0.2),
901
+ final isSelected = voice.id == selectedVoiceId;
902
+
903
+ return Material(
904
+ color: Colors.transparent,
905
+ child: Container(
906
+ height: 36,
907
+ decoration: BoxDecoration(
908
+ color: isSelected
909
+ ? Colors.green.withValues(alpha: 0.1)
910
+ : isPlaying
911
+ ? accent.withValues(alpha: 0.1)
912
+ : Colors.transparent,
913
+ borderRadius: BorderRadius.circular(20),
914
+ border: Border.all(
915
+ color: isSelected
916
+ ? Colors.green.withValues(alpha: 0.5)
917
+ : isPlaying
918
+ ? accent.withValues(alpha: 0.3)
919
+ : theme.colorScheme.outline.withValues(alpha: 0.2),
920
+ ),
921
+ ),
922
+ child: Row(
923
+ mainAxisSize: MainAxisSize.min,
924
+ children: [
925
+ InkWell(
926
+ onTap: () => _selectVoice(voice),
927
+ borderRadius: const BorderRadius.horizontal(
928
+ left: Radius.circular(20),
929
+ ),
930
+ child: Padding(
931
+ padding: const EdgeInsets.only(
932
+ left: 12,
933
+ right: 8,
934
+ top: 8,
935
+ bottom: 8,
936
+ ),
937
+ child: Row(
938
+ children: [
939
+ if (isSelected) ...[
940
+ const Icon(
941
+ Icons.check_circle,
942
+ size: 16,
943
+ color: Colors.green,
944
+ ),
945
+ const SizedBox(width: 6),
946
+ ],
947
+ Text(
948
+ voice.name,
949
+ style: theme.textTheme.labelSmall?.copyWith(
950
+ color: isSelected
951
+ ? Colors.green
952
+ : isPlaying
953
+ ? accent
954
+ : theme.colorScheme.onSurface,
955
+ fontWeight: isSelected || isPlaying
956
+ ? FontWeight.bold
957
+ : FontWeight.normal,
958
+ ),
959
+ ),
960
+ ],
961
+ ),
962
+ ),
963
+ ),
964
+ Container(
965
+ width: 1,
966
+ height: 20,
967
+ color: isSelected
968
+ ? Colors.green.withValues(alpha: 0.3)
969
+ : isPlaying
970
+ ? accent.withValues(alpha: 0.2)
971
+ : theme.colorScheme.outline.withValues(alpha: 0.2),
972
+ ),
973
+ InkWell(
974
+ onTap: () => _previewVoice(voice),
975
+ borderRadius: const BorderRadius.horizontal(
976
+ right: Radius.circular(20),
977
+ ),
978
+ child: Padding(
979
+ padding: const EdgeInsets.symmetric(
980
+ horizontal: 10,
981
+ vertical: 8,
982
+ ),
983
+ child: Icon(
984
+ isPlaying ? Icons.stop : Icons.play_arrow,
985
+ size: 16,
986
+ color: isPlaying
987
+ ? accent
988
+ : theme.colorScheme.onSurface.withValues(alpha: 0.5),
989
+ ),
990
+ ),
991
+ ),
992
+ ],
993
+ ),
994
),
707
- onPressed: () => _selectAndPreview(voice),
995
);
996
}
997
711
- Future<void> _selectAndPreview(Voice voice) async {
712
- // Set as default voice for this engine
998
+ void _selectVoice(Voice voice) {
999
ref.read(settingsProvider.notifier).setTtsVoiceId(voice.id);
1000
+ }
1001
715
- // Preview the voice
1002
+ Future<void> _previewVoice(Voice voice) async {
1003
if (_playingVoice == voice) {
1004
await ref.read(tts.ttsProvider.notifier).stop();
1005
if (mounted) setState(() => _playingVoice = null);