@tduncan / Cortana / commits / 2378cf3

feat: implement multi-chunk TTS synthesis via isolated worker and add playback control bar to chat UI

abdulmominsakib committed May 10, 2026 at 18:45 UTC 2378cf35714515c6ebc3192018d0e54b953c71f8
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);