@tduncan / Cortana / commits / 3df8edf

feat: migrate TTS to sherpa_onnx, remove Kokoro engine

abdulmominsakib committed May 16, 2026 at 22:53 UTC 3df8edf6b1b7693e889683726ea78fe6ae9b61e7
26 files changed +1048 -1668
android/app/build.gradle.kts
+3 -7
@@ -36,13 +36,9 @@ android {
36 // For more information, see: https://flutter.dev/to/review-gradle-config.
37 minSdk = 26
38 targetSdk = flutter.targetSdkVersion
39 - versionCode = flutter.versionCode
40 - versionName = flutter.versionName
41 - multiDexEnabled = true
42 -
43 - ndk {
44 - abiFilters += listOf("arm64-v8a")
45 - }
39 + versionCode = flutter.versionCode
40 + versionName = flutter.versionName
41 + multiDexEnabled = true
42 }
43
44 signingConfigs {
android/app/src/main/kotlin/pro/momin/localmind/MainActivity.kt
+20
@@ -1,12 +1,15 @@
1 package pro.momin.localmind
2
3 import androidx.annotation.NonNull
4 +import android.app.ActivityManager
5 +import android.content.Context
6 import io.flutter.embedding.android.FlutterActivity
7 import io.flutter.embedding.engine.FlutterEngine
8 import io.flutter.plugin.common.MethodChannel
9
10 class MainActivity : FlutterActivity() {
11 private val CHANNEL = "localmind/chat_background"
12 + private val MEMORY_CHANNEL = "localmind/device_memory"
13
14 override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) {
15 super.configureFlutterEngine(flutterEngine)
@@ -25,5 +28,22 @@ class MainActivity : FlutterActivity() {
28 }
29 }
30 }
31 +
32 + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, MEMORY_CHANNEL).setMethodCallHandler { call, result ->
33 + when (call.method) {
34 + "getMemoryInfo" -> {
35 + val activityManager = getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
36 + val memoryInfo = ActivityManager.MemoryInfo()
37 + activityManager.getMemoryInfo(memoryInfo)
38 + result.success(
39 + mapOf(
40 + "totalMemoryMb" to (memoryInfo.totalMem / (1024 * 1024)),
41 + "availableMemoryMb" to (memoryInfo.availMem / (1024 * 1024))
42 + )
43 + )
44 + }
45 + else -> result.notImplemented()
46 + }
47 + }
48 }
49 }
assets/espeak-data.zip
Binary files a/assets/espeak-data.zip and /dev/null differ
ios/Podfile.lock
+55
@@ -1,4 +1,11 @@
1 PODS:
2 + - audioplayers_darwin (0.0.1):
3 + - Flutter
4 + - FlutterMacOS
5 + - clipboard (3.0.9):
6 + - Flutter
7 + - device_info_plus (0.0.1):
8 + - Flutter
9 - DKImagePickerController/Core (4.3.9):
10 - DKImagePickerController/ImageDataManager
11 - DKImagePickerController/Resource
@@ -34,6 +41,10 @@ PODS:
41 - DKImagePickerController/PhotoGallery
42 - Flutter
43 - Flutter (1.0.0)
44 + - flutter_litert_lm (0.3.0):
45 + - Flutter
46 + - flutter_local_notifications (0.0.1):
47 + - Flutter
48 - flutter_secure_storage (6.0.0):
49 - Flutter
50 - flutter_tts (0.0.1):
@@ -44,22 +55,39 @@ PODS:
55 - objectbox_flutter_libs (0.0.1):
56 - Flutter
57 - ObjectBox (= 5.3.0-beta.4)
58 + - package_info_plus (0.4.5):
59 + - Flutter
60 - SDWebImage (5.21.7):
61 - SDWebImage/Core (= 5.21.7)
62 - SDWebImage/Core (5.21.7)
63 - shared_preferences_foundation (0.0.1):
64 - Flutter
65 - FlutterMacOS
66 + - sherpa_onnx_ios (1.13.2):
67 + - Flutter
68 - SwiftyGif (5.4.5)
69 + - url_launcher_ios (0.0.1):
70 + - Flutter
71 + - wakelock_plus (0.0.1):
72 + - Flutter
73
74 DEPENDENCIES:
75 + - audioplayers_darwin (from `.symlinks/plugins/audioplayers_darwin/darwin`)
76 + - clipboard (from `.symlinks/plugins/clipboard/ios`)
77 + - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
78 - file_picker (from `.symlinks/plugins/file_picker/ios`)
79 - Flutter (from `Flutter`)
80 + - flutter_litert_lm (from `.symlinks/plugins/flutter_litert_lm/ios`)
81 + - flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`)
82 - flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`)
83 - flutter_tts (from `.symlinks/plugins/flutter_tts/ios`)
84 - haptic_feedback (from `.symlinks/plugins/haptic_feedback/ios`)
85 - objectbox_flutter_libs (from `.symlinks/plugins/objectbox_flutter_libs/ios`)
86 + - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
87 - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
88 + - sherpa_onnx_ios (from `.symlinks/plugins/sherpa_onnx_ios/ios`)
89 + - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
90 + - wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`)
91
92 SPEC REPOS:
93 trunk:
@@ -70,10 +98,20 @@ SPEC REPOS:
98 - SwiftyGif
99
100 EXTERNAL SOURCES:
101 + audioplayers_darwin:
102 + :path: ".symlinks/plugins/audioplayers_darwin/darwin"
103 + clipboard:
104 + :path: ".symlinks/plugins/clipboard/ios"
105 + device_info_plus:
106 + :path: ".symlinks/plugins/device_info_plus/ios"
107 file_picker:
108 :path: ".symlinks/plugins/file_picker/ios"
109 Flutter:
110 :path: Flutter
111 + flutter_litert_lm:
112 + :path: ".symlinks/plugins/flutter_litert_lm/ios"
113 + flutter_local_notifications:
114 + :path: ".symlinks/plugins/flutter_local_notifications/ios"
115 flutter_secure_storage:
116 :path: ".symlinks/plugins/flutter_secure_storage/ios"
117 flutter_tts:
@@ -82,22 +120,39 @@ EXTERNAL SOURCES:
120 :path: ".symlinks/plugins/haptic_feedback/ios"
121 objectbox_flutter_libs:
122 :path: ".symlinks/plugins/objectbox_flutter_libs/ios"
123 + package_info_plus:
124 + :path: ".symlinks/plugins/package_info_plus/ios"
125 shared_preferences_foundation:
126 :path: ".symlinks/plugins/shared_preferences_foundation/darwin"
127 + sherpa_onnx_ios:
128 + :path: ".symlinks/plugins/sherpa_onnx_ios/ios"
129 + url_launcher_ios:
130 + :path: ".symlinks/plugins/url_launcher_ios/ios"
131 + wakelock_plus:
132 + :path: ".symlinks/plugins/wakelock_plus/ios"
133
134 SPEC CHECKSUMS:
135 + audioplayers_darwin: f15e209a3e856d1a7edcf98dc029f484fead2242
136 + clipboard: 1dfb1b37c0b722d20b0ca76a89bb2e7194e85811
137 + device_info_plus: bf2e3232933866d73fe290f2942f2156cdd10342
138 DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
139 DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
140 file_picker: b159e0c068aef54932bb15dc9fd1571818edaf49
141 Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
142 + flutter_litert_lm: 07a899d1d3f22806951c3be384bd13dae3951a41
143 + flutter_local_notifications: df98d66e515e1ca797af436137b4459b160ad8c9
144 flutter_secure_storage: d33dac7ae2ea08509be337e775f6b59f1ff45f12
145 flutter_tts: 91ad1884033cd95ef6e9338754adad54f01f090f
146 haptic_feedback: a366a27ec760f0e865651605b95de8a9bb04ef01
147 ObjectBox: eccb95ea2054c39d81dfa2d4ccc5f1e31187228a
148 objectbox_flutter_libs: 893a4322e8ec87438912bae6dfa745929adc6bf5
149 + package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4
150 SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf
151 shared_preferences_foundation: 5086985c1d43c5ba4d5e69a4e8083a389e2909e6
152 + sherpa_onnx_ios: 9f536d001daeaca36fc812fa1b1234870df3bc91
153 SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
154 + url_launcher_ios: bb13df5870e8c4234ca12609d04010a21be43dfa
155 + wakelock_plus: 76957ab028e12bfa4e66813c99e46637f367fc7e
156
157 PODFILE CHECKSUM: 53a6aebc29ccee84c41f92f409fc20cd4ca011f1
158
lib/core/device/device_memory_service.dart
+30 -17
@@ -1,4 +1,6 @@
1 import 'dart:io';
2 +
3 +import 'package:flutter/services.dart';
4 import 'package:device_info_plus/device_info_plus.dart';
5
6 class DeviceMemoryInfo {
@@ -10,8 +12,6 @@ class DeviceMemoryInfo {
12 required this.availableMemoryMb,
13 });
14
13 - bool get isLowRam => totalMemoryMb < 7000; // Threshold for 8GB RAM devices
14 -
15 bool hasEnoughRam(int requiredMb) => availableMemoryMb >= requiredMb;
16 bool isOversized(int minRamMb) => totalMemoryMb < minRamMb;
17
@@ -28,6 +28,7 @@ class DeviceMemoryInfo {
28
29 class DeviceMemoryService {
30 final DeviceInfoPlugin _deviceInfo = DeviceInfoPlugin();
31 + static const MethodChannel _memoryChannel = MethodChannel('localmind/device_memory');
32
33 Future<DeviceMemoryInfo> getMemoryInfo() async {
34 int totalMb = 0;
@@ -35,28 +36,40 @@ class DeviceMemoryService {
36
37 if (Platform.isAndroid) {
38 try {
38 - final androidInfo = await _deviceInfo.androidInfo;
39 - // physicalRamSize is in bytes for device_info_plus 4.0.0+
40 - totalMb = androidInfo.physicalRamSize ~/ (1024 * 1024);
39 + final memory = await _memoryChannel.invokeMapMethod<String, dynamic>('getMemoryInfo');
40 + if (memory != null) {
41 + totalMb = (memory['totalMemoryMb'] as num?)?.toInt() ?? 0;
42 + availableMb = (memory['availableMemoryMb'] as num?)?.toInt() ?? 0;
43 + }
44 } catch (_) {}
45
46 try {
44 - final file = File('/proc/meminfo');
45 - if (await file.exists()) {
46 - final memInfo = await file.readAsLines();
47 - for (final line in memInfo) {
48 - if (line.startsWith('MemAvailable:')) {
49 - final parts = line.split(RegExp(r'\s+'));
50 - if (parts.length >= 2) {
51 - final kb = int.tryParse(parts[1]) ?? 0;
52 - availableMb = kb ~/ 1024;
47 + final androidInfo = await _deviceInfo.androidInfo;
48 + if (totalMb == 0) {
49 + // physicalRamSize is in bytes for device_info_plus 4.0.0+
50 + totalMb = androidInfo.physicalRamSize ~/ (1024 * 1024);
51 + }
52 + } catch (_) {}
53 +
54 + if (availableMb == 0) {
55 + try {
56 + final file = File('/proc/meminfo');
57 + if (await file.exists()) {
58 + final memInfo = await file.readAsLines();
59 + for (final line in memInfo) {
60 + if (line.startsWith('MemAvailable:')) {
61 + final parts = line.split(RegExp(r'\s+'));
62 + if (parts.length >= 2) {
63 + final kb = int.tryParse(parts[1]) ?? 0;
64 + availableMb = kb ~/ 1024;
65 + }
66 + break;
67 }
54 - break;
68 }
69 }
70 + } catch (_) {
71 + // Fallback or ignore
72 }
58 - } catch (_) {
59 - // Fallback or ignore
73 }
74 } else if (Platform.isMacOS || Platform.isLinux) {
75 // Optional: Add support for desktop if needed for testing
lib/core/models/enums.dart
+109 -57
@@ -1,4 +1,4 @@
1 -enum EngineId { system, kitten, kokoro, piper }
1 +enum EngineId { system, kitten, piper }
2
3 class EngineMeta {
4 final String name;
@@ -35,18 +35,9 @@ class EngineMeta {
35 accentColor: 0xFFFF9800,
36 );
37
38 - static const kokoro = EngineMeta(
39 - name: 'Kokoro TTS',
40 - tagline: 'Premium neural voices',
41 - sizeMb: 170,
42 - ramMb: 250,
43 - voiceCount: 22,
44 - accentColor: 0xFFE91E63,
45 - );
46 -
38 static const piper = EngineMeta(
48 - name: 'Piper TTS',
49 - tagline: 'Open-source neural voices',
39 + name: 'Sherpa ONNX VITS',
40 + tagline: 'Offline Piper voices',
41 sizeMb: 50,
42 ramMb: 100,
43 voiceCount: 2,
@@ -57,8 +48,6 @@ class EngineMeta {
48 switch (engine) {
49 case EngineId.kitten:
50 return kitten;
60 - case EngineId.kokoro:
61 - return kokoro;
51 case EngineId.piper:
52 return piper;
53 default:
@@ -71,6 +60,7 @@ class Voice {
60 final String id;
61 final String name;
62 final EngineId engine;
63 + final String family;
64 final String? language;
65 final String gender;
66
@@ -78,6 +68,7 @@ class Voice {
68 required this.id,
69 required this.name,
70 required this.engine,
71 + required this.family,
72 this.language,
73 required this.gender,
74 });
@@ -87,8 +78,6 @@ List<Voice> voicesForEngine(EngineId engine) {
78 switch (engine) {
79 case EngineId.kitten:
80 return kittenVoices;
90 - case EngineId.kokoro:
91 - return kokoroVoices;
81 case EngineId.piper:
82 return piperVoices;
83 default:
@@ -96,57 +85,106 @@ List<Voice> voicesForEngine(EngineId engine) {
85 }
86 }
87
99 -const List<Voice> kittenVoices = [
100 - Voice(id: 'Bella', name: 'Bella', engine: EngineId.kitten, language: 'en', gender: 'f'),
101 - Voice(id: 'Jasper', name: 'Jasper', engine: EngineId.kitten, language: 'en', gender: 'm'),
102 - Voice(id: 'Luna', name: 'Luna', engine: EngineId.kitten, language: 'en', gender: 'f'),
103 - Voice(id: 'Bruno', name: 'Bruno', engine: EngineId.kitten, language: 'en', gender: 'm'),
104 - Voice(id: 'Rosie', name: 'Rosie', engine: EngineId.kitten, language: 'en', gender: 'f'),
105 - Voice(id: 'Hugo', name: 'Hugo', engine: EngineId.kitten, language: 'en', gender: 'm'),
106 - Voice(id: 'Kiki', name: 'Kiki', engine: EngineId.kitten, language: 'en', gender: 'f'),
107 - Voice(id: 'Leo', name: 'Leo', engine: EngineId.kitten, language: 'en', gender: 'm'),
108 -];
88 +Map<String, List<Voice>> voicesGroupedByFamily(EngineId engine) {
89 + final result = <String, List<Voice>>{};
90 + for (final voice in voicesForEngine(engine)) {
91 + result.putIfAbsent(voice.family, () => <Voice>[]).add(voice);
92 + }
93 + return result;
94 +}
95
110 -const List<Voice> kokoroVoices = [
111 - Voice(id: 'af_heart', name: 'Heart', engine: EngineId.kokoro, language: 'en', gender: 'f'),
112 - Voice(id: 'af_bella', name: 'Bella', engine: EngineId.kokoro, language: 'en', gender: 'f'),
113 - Voice(id: 'af_nicole', name: 'Nicole', engine: EngineId.kokoro, language: 'en', gender: 'f'),
114 - Voice(id: 'af_aoihana', name: 'Aoihana', engine: EngineId.kokoro, language: 'en', gender: 'f'),
115 - Voice(id: 'af_sarah', name: 'Sarah', engine: EngineId.kokoro, language: 'en', gender: 'f'),
116 - Voice(id: 'af_sky', name: 'Sky', engine: EngineId.kokoro, language: 'en', gender: 'f'),
117 - Voice(id: 'am_adam', name: 'Adam', engine: EngineId.kokoro, language: 'en', gender: 'm'),
118 - Voice(id: 'am_michael', name: 'Michael', engine: EngineId.kokoro, language: 'en', gender: 'm'),
119 - Voice(id: 'bf_isabelle', name: 'Isabelle', engine: EngineId.kokoro, language: 'en', gender: 'f'),
120 - Voice(id: 'bf_alice', name: 'Alice', engine: EngineId.kokoro, language: 'en', gender: 'f'),
121 - Voice(id: 'bm_george', name: 'George', engine: EngineId.kokoro, language: 'en', gender: 'm'),
122 - Voice(id: 'bm_lewis', name: 'Lewis', engine: EngineId.kokoro, language: 'en', gender: 'm'),
96 +const List<Voice> kittenVoices = [
97 + Voice(
98 + id: 'Jasper',
99 + name: 'Jasper',
100 + engine: EngineId.kitten,
101 + family: 'Kitten',
102 + language: 'en',
103 + gender: 'm',
104 + ),
105 + Voice(
106 + id: 'Bella',
107 + name: 'Bella',
108 + engine: EngineId.kitten,
109 + family: 'Kitten',
110 + language: 'en',
111 + gender: 'f',
112 + ),
113 + Voice(
114 + id: 'Bruno',
115 + name: 'Bruno',
116 + engine: EngineId.kitten,
117 + family: 'Kitten',
118 + language: 'en',
119 + gender: 'm',
120 + ),
121 + Voice(
122 + id: 'Luna',
123 + name: 'Luna',
124 + engine: EngineId.kitten,
125 + family: 'Kitten',
126 + language: 'en',
127 + gender: 'f',
128 + ),
129 + Voice(
130 + id: 'Hugo',
131 + name: 'Hugo',
132 + engine: EngineId.kitten,
133 + family: 'Kitten',
134 + language: 'en',
135 + gender: 'm',
136 + ),
137 + Voice(
138 + id: 'Rosie',
139 + name: 'Rosie',
140 + engine: EngineId.kitten,
141 + family: 'Kitten',
142 + language: 'en',
143 + gender: 'f',
144 + ),
145 + Voice(
146 + id: 'Leo',
147 + name: 'Leo',
148 + engine: EngineId.kitten,
149 + family: 'Kitten',
150 + language: 'en',
151 + gender: 'm',
152 + ),
153 + Voice(
154 + id: 'Kiki',
155 + name: 'Kiki',
156 + engine: EngineId.kitten,
157 + family: 'Kitten',
158 + language: 'en',
159 + gender: 'f',
160 + ),
161 ];
162
163 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'),
164 + Voice(
165 + id: 'en_US-lessac-medium',
166 + name: 'Lessac (US)',
167 + engine: EngineId.piper,
168 + family: 'Piper',
169 + language: 'en',
170 + gender: 'f',
171 + ),
172 + Voice(
173 + id: 'en_US-ryan-medium',
174 + name: 'Ryan (US)',
175 + engine: EngineId.piper,
176 + family: 'Piper',
177 + language: 'en',
178 + gender: 'm',
179 + ),
180 ];
181
130 -enum ServerType { lmStudio, openAICompatible, ollama, openRouter, onDevice }
131 -
132 -enum ConnectionStatus { connected, disconnected, checking, error }
133 -
134 -enum MessageRole { user, assistant, system, tool }
135 -
136 -enum MessageStatus { sending, streaming, complete, error }
137 -
138 -enum ModelStatus { unloaded, loading, loaded, preloaded, thinking }
139 -
140 -enum OnDeviceEngineStatus { notLoaded, loading, loaded, error }
141 -
142 -enum LiteLmBackendType { cpu, gpu, npu }
143 -
182 EngineId engineIdFromString(String value) {
183 switch (value) {
184 case 'kitten':
185 return EngineId.kitten;
148 - case 'kokoro':
149 - return EngineId.kokoro;
186 + case 'piper':
187 + return EngineId.piper;
188 default:
189 return EngineId.system;
190 }
@@ -162,6 +200,20 @@ Voice? voiceFromSettings(String? voiceId, EngineId engine) {
200 }
201 }
202
203 +enum ServerType { lmStudio, openAICompatible, ollama, openRouter, onDevice }
204 +
205 +enum ConnectionStatus { connected, disconnected, checking, error }
206 +
207 +enum MessageRole { user, assistant, system, tool }
208 +
209 +enum MessageStatus { sending, streaming, complete, error }
210 +
211 +enum ModelStatus { unloaded, loading, loaded, preloaded, thinking }
212 +
213 +enum OnDeviceEngineStatus { notLoaded, loading, loaded, error }
214 +
215 +enum LiteLmBackendType { cpu, gpu, npu }
216 +
217 enum OnDeviceModelState {
218 notDownloaded,
219 downloading,
lib/core/providers/app_providers.dart
-3
@@ -3,7 +3,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
3 import '../../features/settings/data/models/app_settings.dart';
4 import '../models/enums.dart';
5 import '../../features/tts/data/kitten_tts_model.dart';
6 -import '../../features/tts/data/kokoro_tts_model.dart';
6 import 'storage_providers.dart';
7 import '../theme/app_theme.dart';
8
@@ -95,8 +94,6 @@ class SettingsNotifier extends Notifier<AppSettings> {
94 void setTtsSpeed(double value) => _update(state.copyWith(ttsSpeed: value));
95 void setKittenTtsModelVariant(KittenTtsModelVariant value) =>
96 _update(state.copyWith(kittenTtsModelVariant: value));
98 - void setKokoroTtsModelVariant(KokoroTtsModelVariant value) =>
99 - _update(state.copyWith(kokoroTtsModelVariant: value));
97 void setAutoSpeakEnabled(bool value) =>
98 _update(state.copyWith(autoSpeakEnabled: value));
99
lib/features/models/screens/model_picker_sheet.dart
+9 -37
@@ -765,17 +765,6 @@ class _OnDeviceModelTile extends ConsumerWidget {
765
766 return InkWell(
767 onTap: () {
768 - if (deviceMemory?.isLowRam ?? false) {
769 - ScaffoldMessenger.of(context).showSnackBar(
770 - const SnackBar(
771 - content: Text(
772 - 'On-device models are restricted as your device has less than 8 GB RAM.',
773 - ),
774 - backgroundColor: Colors.orange,
775 - ),
776 - );
777 - return;
778 - }
768 if (isLoaded) {
769 _selectModel(context, ref);
770 } else if (isDownloaded && !isCurrentlyLoading && !isDownloading) {
@@ -948,38 +937,21 @@ class _OnDeviceModelTile extends ConsumerWidget {
937 icon: Icon(
938 Icons.cloud_download_outlined,
939 size: 18,
951 - color: (deviceMemory?.isLowRam ?? false)
952 - ? Colors.orange.withValues(alpha: 0.5)
953 - : (isDark ? const Color(0xFF555555) : const Color(0xFF999999)),
940 + color: isDark ? const Color(0xFF555555) : const Color(0xFF999999),
941 ),
955 - tooltip: (deviceMemory?.isLowRam ?? false)
956 - ? 'Restricted due to low RAM'
957 - : 'Download model',
958 - onPressed: (deviceMemory?.isLowRam ?? false)
959 - ? () {
960 - ScaffoldMessenger.of(context).showSnackBar(
961 - const SnackBar(
962 - content: Text(
963 - 'Downloading on-device models is restricted on devices with less than 8 GB RAM.',
964 - ),
965 - backgroundColor: Colors.orange,
966 - ),
967 - );
968 - }
969 - : () {
970 - ref
971 - .read(foregroundDownloadNotifierProvider.notifier)
972 - .startDownload(model.id);
973 - },
942 + tooltip: 'Download model',
943 + onPressed: () {
944 + ref
945 + .read(foregroundDownloadNotifierProvider.notifier)
946 + .startDownload(model.id);
947 + },
948 ),
949 const SizedBox(width: 4),
950 Text(
977 - (deviceMemory?.isLowRam ?? false) ? 'Restricted' : 'Not downloaded',
951 + 'Not downloaded',
952 style: TextStyle(
953 fontSize: 11,
980 - color: (deviceMemory?.isLowRam ?? false)
981 - ? Colors.orange.withValues(alpha: 0.7)
982 - : (isDark ? const Color(0xFF666666) : const Color(0xFF999999)),
954 + color: isDark ? const Color(0xFF666666) : const Color(0xFF999999),
955 ),
956 ),
957 ],
lib/features/on_device/views/model_manager_screen.dart
+2 -46
@@ -98,28 +98,6 @@ class OnDeviceModelManagerScreen extends ConsumerWidget {
98 style: const TextStyle(color: Colors.red),
99 ),
100 ),
101 - if (deviceMemoryAsync.value?.isLowRam ?? false)
102 - Container(
103 - padding: const EdgeInsets.all(12),
104 - margin: const EdgeInsets.only(bottom: 16),
105 - decoration: BoxDecoration(
106 - color: Colors.orange.withValues(alpha: 0.1),
107 - borderRadius: BorderRadius.circular(8),
108 - border: Border.all(color: Colors.orange),
109 - ),
110 - child: const Row(
111 - children: [
112 - Icon(Icons.warning_amber_rounded, color: Colors.orange, size: 20),
113 - SizedBox(width: 8),
114 - Expanded(
115 - child: Text(
116 - 'Running or downloading local LLMs is restricted on this device because it has less than 8 GB RAM. This is to ensure device stability.',
117 - style: TextStyle(color: Colors.orange, fontWeight: FontWeight.bold),
118 - ),
119 - ),
120 - ],
121 - ),
122 - ),
101 Text(
102 'Available Models',
103 style: theme.textTheme.titleMedium?.copyWith(
@@ -611,18 +589,7 @@ class _ModelCard extends ConsumerWidget {
589 else
590 ShadButton.outline(
591 size: ShadButtonSize.sm,
614 - onPressed: (deviceMemory?.isLowRam ?? false)
615 - ? () {
616 - ScaffoldMessenger.of(context).showSnackBar(
617 - const SnackBar(
618 - content: Text(
619 - 'Loading local LLMs is restricted on devices with less than 8 GB RAM.',
620 - ),
621 - backgroundColor: Colors.orange,
622 - ),
623 - );
624 - }
625 - : () => _loadModel(context, ref),
592 + onPressed: () => _loadModel(context, ref),
593 child: const Text('Load'),
594 ),
595 const SizedBox(width: 8),
@@ -733,18 +700,7 @@ class _ModelCard extends ConsumerWidget {
700 const Spacer(),
701 ShadButton.outline(
702 size: ShadButtonSize.sm,
736 - onPressed: (deviceMemory?.isLowRam ?? false)
737 - ? () {
738 - ScaffoldMessenger.of(context).showSnackBar(
739 - const SnackBar(
740 - content: Text(
741 - 'Downloading local LLMs is restricted on devices with less than 8 GB RAM.',
742 - ),
743 - backgroundColor: Colors.orange,
744 - ),
745 - );
746 - }
747 - : () => _startDownload(context, ref),
703 + onPressed: () => _startDownload(context, ref),
704 child: const Text('Download'),
705 ),
706 ],
lib/features/onboarding/screens/onboarding_model_download_screen.dart
+1 -12
@@ -414,18 +414,7 @@ class _ModelCard extends ConsumerWidget {
414 else
415 ShadButton.outline(
416 size: ShadButtonSize.sm,
417 - onPressed: (deviceMemory?.isLowRam ?? false)
418 - ? () {
419 - ScaffoldMessenger.of(context).showSnackBar(
420 - const SnackBar(
421 - content: Text(
422 - 'Downloading local LLMs is restricted on devices with less than 8 GB RAM.',
423 - ),
424 - backgroundColor: Colors.orange,
425 - ),
426 - );
427 - }
428 - : () => _startDownload(context, ref),
417 + onPressed: () => _startDownload(context, ref),
418 child: const Text('Download'),
419 ),
420 ],
lib/features/onboarding/screens/onboarding_server_type_screen.dart
+11 -43
@@ -6,7 +6,6 @@ import 'package:shadcn_ui/shadcn_ui.dart';
6
7 import '../../../core/models/enums.dart';
8 import '../../../core/providers/app_providers.dart';
9 -import '../../../core/providers/device_info_providers.dart';
9 import '../../../core/routes/app_routes.dart';
10
11 class OnboardingServerTypeScreen extends ConsumerStatefulWidget {
@@ -86,48 +85,17 @@ class _OnboardingServerTypeScreenState
85 ),
86 ),
87 const SizedBox(height: 24),
89 - Consumer(
90 - builder: (context, ref, child) {
91 - final memoryAsync = ref.watch(deviceMemoryProvider);
92 - return memoryAsync.maybeWhen(
93 - data: (memory) {
94 - final isLowRam = memory.isLowRam;
95 - return _buildServerCard(
96 - type: ServerType.onDevice,
97 - title: 'On-Device',
98 - subtitle: isLowRam
99 - ? 'RESTRICTED (LOW RAM)'
100 - : 'NO SERVER NEEDED',
101 - iconWidget: Icon(
102 - Icons.phone_android_rounded,
103 - color: isLowRam
104 - ? theme.colorScheme.onSurface.withValues(
105 - alpha: 0.3,
106 - )
107 - : (_selectedType == ServerType.onDevice
108 - ? theme.colorScheme.primary
109 - : theme.colorScheme.onSurface),
110 - ),
111 - theme: theme,
112 - disabled: isLowRam,
113 - disabledReason:
114 - 'Local LLMs require at least 8 GB RAM to run smoothly on your device.',
115 - );
116 - },
117 - orElse: () => _buildServerCard(
118 - type: ServerType.onDevice,
119 - title: 'On-Device',
120 - subtitle: 'NO SERVER NEEDED',
121 - iconWidget: Icon(
122 - Icons.phone_android_rounded,
123 - color: _selectedType == ServerType.onDevice
124 - ? theme.colorScheme.primary
125 - : theme.colorScheme.onSurface,
126 - ),
127 - theme: theme,
128 - ),
129 - );
130 - },
88 + _buildServerCard(
89 + type: ServerType.onDevice,
90 + title: 'On-Device',
91 + subtitle: 'NO SERVER NEEDED',
92 + iconWidget: Icon(
93 + Icons.phone_android_rounded,
94 + color: _selectedType == ServerType.onDevice
95 + ? theme.colorScheme.primary
96 + : theme.colorScheme.onSurface,
97 + ),
98 + theme: theme,
99 ),
100 const SizedBox(height: 16),
101 _buildServerCard(
lib/features/settings/data/models/app_settings.dart
+6 -19
@@ -4,11 +4,12 @@ import 'package:flutter/material.dart';
4
5 import '../../../../core/models/enums.dart';
6 import '../../../../features/tts/data/kitten_tts_model.dart';
7 -import '../../../../features/tts/data/kokoro_tts_model.dart';
7
8 enum SyntaxThemeName { light, dark }
9
10 class AppSettings {
11 + static const Object _unset = Object();
12 +
13 final double temperature;
14 final double topP;
15 final int maxTokens;
@@ -34,7 +35,6 @@ class AppSettings {
35 final String? ttsVoiceId;
36 final double ttsSpeed;
37 final KittenTtsModelVariant kittenTtsModelVariant;
37 - final KokoroTtsModelVariant kokoroTtsModelVariant;
38 final bool autoSpeakEnabled;
39
40 AppSettings({
@@ -63,7 +63,6 @@ class AppSettings {
63 this.ttsVoiceId,
64 this.ttsSpeed = 1.0,
65 this.kittenTtsModelVariant = KittenTtsModelVariant.nanoInt8,
66 - this.kokoroTtsModelVariant = KokoroTtsModelVariant.int8,
66 this.autoSpeakEnabled = false,
67 });
68
@@ -90,10 +89,9 @@ class AppSettings {
89 SyntaxThemeName? codeThemeLight,
90 LiteLmBackendType? preferredBackend,
91 EngineId? ttsEngine,
93 - String? ttsVoiceId,
92 + Object? ttsVoiceId = _unset,
93 double? ttsSpeed,
94 KittenTtsModelVariant? kittenTtsModelVariant,
96 - KokoroTtsModelVariant? kokoroTtsModelVariant,
95 bool? autoSpeakEnabled,
96 }) {
97 return AppSettings(
@@ -122,12 +120,12 @@ class AppSettings {
120 codeThemeLight: codeThemeLight ?? this.codeThemeLight,
121 preferredBackend: preferredBackend ?? this.preferredBackend,
122 ttsEngine: ttsEngine ?? this.ttsEngine,
125 - ttsVoiceId: ttsVoiceId ?? this.ttsVoiceId,
123 + ttsVoiceId: identical(ttsVoiceId, _unset)
124 + ? this.ttsVoiceId
125 + : ttsVoiceId as String?,
126 ttsSpeed: ttsSpeed ?? this.ttsSpeed,
127 kittenTtsModelVariant:
128 kittenTtsModelVariant ?? this.kittenTtsModelVariant,
129 - kokoroTtsModelVariant:
130 - kokoroTtsModelVariant ?? this.kokoroTtsModelVariant,
129 autoSpeakEnabled: autoSpeakEnabled ?? this.autoSpeakEnabled,
130 );
131 }
@@ -159,7 +157,6 @@ class AppSettings {
157 'ttsVoiceId': ttsVoiceId,
158 'ttsSpeed': ttsSpeed,
159 'kittenTtsModelVariant': kittenTtsModelVariant.name,
162 - 'kokoroTtsModelVariant': kokoroTtsModelVariant.name,
160 'autoSpeakEnabled': autoSpeakEnabled,
161 };
162 }
@@ -191,7 +188,6 @@ class AppSettings {
188 ttsVoiceId: map['ttsVoiceId'],
189 ttsSpeed: map['ttsSpeed']?.toDouble() ?? 1.0,
190 kittenTtsModelVariant: _parseKittenVariant(map['kittenTtsModelVariant']),
194 - kokoroTtsModelVariant: _parseKokoroVariant(map['kokoroTtsModelVariant']),
191 autoSpeakEnabled: map['autoSpeakEnabled'] ?? false,
192 );
193 }
@@ -215,15 +211,6 @@ class AppSettings {
211 return KittenTtsModelVariant.nanoInt8;
212 }
213
218 - static KokoroTtsModelVariant _parseKokoroVariant(dynamic value) {
219 - if (value is String) {
220 - try {
221 - return KokoroTtsModelVariant.values.byName(value);
222 - } catch (_) {}
223 - }
224 - return KokoroTtsModelVariant.int8;
225 - }
226 -
214 String toJson() => json.encode(toMap());
215
216 factory AppSettings.fromJson(String source) =>
lib/features/settings/views/settings_screen.dart
+30 -7
@@ -955,11 +955,6 @@ class _EngineDropdown extends StatelessWidget {
955 'Kitten TTS',
956 Icons.auto_awesome,
957 ),
958 - _engineItem(
959 - EngineId.kokoro,
960 - 'Kokoro TTS',
961 - Icons.auto_awesome,
962 - ),
958 ],
959 onChanged: (v) {
960 if (v != null) onChanged(v);
@@ -1013,11 +1008,18 @@ class _VoiceSelector extends StatelessWidget {
1008 @override
1009 Widget build(BuildContext context) {
1010 final voices = voicesForEngine(engine);
1011 + final resolvedCurrentVoiceId = voiceFromSettings(
1012 + currentVoiceId,
1013 + engine,
1014 + )?.id;
1015
1016 if (voices.isEmpty) return const SizedBox.shrink();
1017
1018 final femaleVoices = voices.where((v) => v.gender == 'f').toList();
1019 final maleVoices = voices.where((v) => v.gender == 'm').toList();
1020 + final otherVoices = voices
1021 + .where((v) => v.gender != 'f' && v.gender != 'm')
1022 + .toList();
1023
1024 return Padding(
1025 padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
@@ -1047,7 +1049,7 @@ class _VoiceSelector extends StatelessWidget {
1049 ...femaleVoices.map(
1050 (v) => _VoiceTile(
1051 voice: v,
1050 - selected: v.id == currentVoiceId,
1052 + selected: v.id == resolvedCurrentVoiceId,
1053 onTap: () => onChanged(v),
1054 isDark: isDark,
1055 ),
@@ -1068,7 +1070,28 @@ class _VoiceSelector extends StatelessWidget {
1070 ...maleVoices.map(
1071 (v) => _VoiceTile(
1072 voice: v,
1071 - selected: v.id == currentVoiceId,
1073 + selected: v.id == resolvedCurrentVoiceId,
1074 + onTap: () => onChanged(v),
1075 + isDark: isDark,
1076 + ),
1077 + ),
1078 + ],
1079 + if (otherVoices.isNotEmpty) ...[
1080 + const SizedBox(height: 8),
1081 + Text(
1082 + 'Other',
1083 + style: TextStyle(
1084 + fontSize: 11,
1085 + color: isDark
1086 + ? const Color(0xFF888888)
1087 + : const Color(0xFF999999),
1088 + ),
1089 + ),
1090 + const SizedBox(height: 4),
1091 + ...otherVoices.map(
1092 + (v) => _VoiceTile(
1093 + voice: v,
1094 + selected: v.id == resolvedCurrentVoiceId,
1095 onTap: () => onChanged(v),
1096 isDark: isDark,
1097 ),
lib/features/tts/data/kitten_tts_downloader.dart
+104 -197
@@ -7,178 +7,132 @@ import 'package:wakelock_plus/wakelock_plus.dart';
7
8 import '../../../core/logger/app_logger.dart';
9 import 'kitten_tts_model.dart';
10 +import 'sherpa_bundle_downloader.dart';
11
11 -/// Downloads KittenTTS model files (config.json, model.onnx, voices.npz)
12 -/// from HuggingFace with resume support and per-file progress reporting.
12 +/// Downloads sherpa-onnx Kitten TTS bundles.
13 class KittenTtsDownloader {
14 final Dio _dio = Dio();
15 final Map<String, CancelToken> _activeDownloads = {};
16
17 - /// Returns the storage directory for a given variant.
18 - Future<Directory> _getVariantDir(KittenTtsModelVariant variant) async {
17 + Future<Directory> _getEngineDir() async {
18 final supportDir = await getApplicationSupportDirectory();
20 - final dir = Directory('${supportDir.path}/kitten_tts/${variant.dirName}');
19 + final dir = Directory('${supportDir.path}/tts_models/kitten');
20 if (!await dir.exists()) {
21 await dir.create(recursive: true);
22 }
23 return dir;
24 }
25
27 - /// Check whether all files for a variant have been downloaded.
28 - Future<bool> isVariantDownloaded(KittenTtsModelVariant variant) async {
29 - final dir = await _getVariantDir(variant);
30 - for (final file in variant.files) {
31 - final path = '${dir.path}/${file.fileName}';
32 - if (!await File(path).exists()) {
33 - return false;
34 - }
35 - }
36 - return true;
26 + Future<Directory> _getVariantDir(KittenTtsModelVariant variant) async {
27 + final engineDir = await _getEngineDir();
28 + return Directory('${engineDir.path}/${variant.bundleDirName}');
29 }
30
39 - /// Get total downloaded bytes for a variant.
40 - Future<int> getDownloadedBytes(KittenTtsModelVariant variant) async {
31 + Future<bool> isVariantDownloaded(KittenTtsModelVariant variant) async {
32 final dir = await _getVariantDir(variant);
42 - int total = 0;
43 - for (final file in variant.files) {
44 - final path = '${dir.path}/${file.fileName}';
45 - final f = File(path);
46 - if (await f.exists()) {
47 - total += await f.length();
48 - }
49 - }
50 - return total;
33 + return await File('${dir.path}/${variant.modelFileName}').exists() &&
34 + await File('${dir.path}/${variant.voicesFileName}').exists() &&
35 + await File('${dir.path}/${variant.tokensFileName}').exists() &&
36 + await Directory('${dir.path}/${variant.dataDirName}').exists();
37 }
38
53 - /// Download all files for [model], emitting per-file progress.
54 - Stream<KittenTtsFileProgress> downloadModel(KittenTtsModel model) async* {
55 - final variantId = model.variant.name;
56 - final dir = await _getVariantDir(model.variant);
57 -
58 - await WakelockPlus.enable();
59 -
60 - try {
61 - for (final file in model.files) {
62 - final cancelToken = CancelToken();
63 - _activeDownloads[variantId] = cancelToken;
64 -
65 - final filePath = '${dir.path}/${file.fileName}';
66 - final partialPath = '$filePath.part';
67 - final partialFile = File(partialPath);
39 + Stream<KittenTtsFileProgress> downloadModel(KittenTtsModel model) {
40 + final variant = model.variant;
41 + final variantKey = variant.name;
42
69 - int receivedBytes = 0;
43 + return Stream<KittenTtsFileProgress>.multi((controller) async {
44 + final engineDir = await _getEngineDir();
45 + final variantDir = await _getVariantDir(variant);
46 + await variantDir.parent.create(recursive: true);
47
71 - if (await partialFile.exists()) {
72 - receivedBytes = await partialFile.length();
73 - Log.info(
74 - 'Resuming KittenTTS ${model.variant.displayName} ${file.fileName} from $receivedBytes bytes',
75 - );
76 - }
77 -
78 - DateTime lastProgressUpdate = DateTime.now();
79 -
80 - try {
81 - final response = await _resolveWithRedirects(
82 - url: file.downloadUrl,
83 - startByte: receivedBytes,
84 - cancelToken: cancelToken,
85 - );
86 -
87 - final totalBytes = (response.headers.value(Headers.contentLengthHeader) != null)
88 - ? int.parse(response.headers.value(Headers.contentLengthHeader)!) + receivedBytes
89 - : file.sizeBytes;
90 -
91 - final IOSink sink = partialFile.openWrite(mode: FileMode.append);
92 - final stream = response.data?.stream;
93 -
94 - if (stream == null) {
95 - await sink.close();
96 - throw DioException(
97 - requestOptions: response.requestOptions,
98 - error: 'Response data stream is null',
99 - );
100 - }
48 + await WakelockPlus.enable();
49
102 - await for (final List<int> chunk in stream) {
103 - if (cancelToken.isCancelled) break;
104 - sink.add(chunk);
105 - receivedBytes += chunk.length;
106 -
107 - final now = DateTime.now();
108 - final elapsedSinceLastUpdate = now.difference(lastProgressUpdate);
109 -
110 - if (elapsedSinceLastUpdate.inMilliseconds >= 500) {
111 - yield KittenTtsFileProgress(
112 - fileName: file.fileName,
113 - variant: model.variant,
114 - receivedBytes: receivedBytes,
50 + try {
51 + final cancelToken = CancelToken();
52 + _activeDownloads[variantKey] = cancelToken;
53 +
54 + final tempDir = await getTemporaryDirectory();
55 + final archivePath = '${tempDir.path}/${variant.bundleDirName}.tar.bz2';
56 + final archiveFile = File(archivePath);
57 + final fileName = '${variant.bundleDirName}.tar.bz2';
58 +
59 + controller.add(
60 + KittenTtsFileProgress(
61 + fileName: fileName,
62 + variant: variant,
63 + receivedBytes: 0,
64 + totalBytes: model.totalSizeBytes,
65 + isComplete: false,
66 + ),
67 + );
68 +
69 + await _dio.download(
70 + variant.tarballUrl,
71 + archiveFile.path,
72 + cancelToken: cancelToken,
73 + onReceiveProgress: (received, total) {
74 + final totalBytes = total > 0 ? total : model.totalSizeBytes;
75 + controller.add(
76 + KittenTtsFileProgress(
77 + fileName: fileName,
78 + variant: variant,
79 + receivedBytes: received,
80 totalBytes: totalBytes,
116 - );
81 + isComplete: false,
82 + ),
83 + );
84 + },
85 + );
86
118 - lastProgressUpdate = now;
119 - }
120 - }
87 + if (cancelToken.isCancelled) {
88 + return;
89 + }
90
122 - await sink.flush();
123 - await sink.close();
91 + await extractTarBz2(
92 + archivePath: archiveFile.path,
93 + targetDir: engineDir,
94 + );
95
125 - await partialFile.rename(filePath);
126 - Log.info(
127 - 'KittenTTS ${model.variant.displayName} ${file.fileName} downloaded',
128 - );
96 + if (await archiveFile.exists()) {
97 + await archiveFile.delete();
98 + }
99
130 - yield KittenTtsFileProgress(
131 - fileName: file.fileName,
132 - variant: model.variant,
133 - receivedBytes: receivedBytes,
134 - totalBytes: receivedBytes,
100 + controller.add(
101 + KittenTtsFileProgress(
102 + fileName: fileName,
103 + variant: variant,
104 + receivedBytes: model.totalSizeBytes,
105 + totalBytes: model.totalSizeBytes,
106 isComplete: true,
136 - );
137 - } catch (e) {
138 - if (e is DioException && CancelToken.isCancel(e)) {
139 - Log.info('KittenTTS download cancelled for $variantId');
140 - return;
141 - }
142 - rethrow;
107 + ),
108 + );
109 + Log.info('Kitten ${variant.displayName} downloaded');
110 + } catch (e, st) {
111 + if (e is DioException && CancelToken.isCancel(e)) {
112 + Log.info('Kitten download cancelled for $variantKey');
113 + } else {
114 + controller.addError(e, st);
115 }
116 + } finally {
117 + _activeDownloads.remove(variantKey);
118 + if (_activeDownloads.isEmpty) {
119 + await WakelockPlus.disable();
120 + }
121 + await controller.close();
122 }
145 - } finally {
146 - _activeDownloads.remove(variantId);
147 - if (_activeDownloads.isEmpty) {
148 - await WakelockPlus.disable();
149 - }
150 - }
123 + });
124 }
125
153 - /// Cancel an in-flight download for [variantId].
126 void cancelDownload(KittenTtsModelVariant variant) {
127 _activeDownloads[variant.name]?.cancel();
128 }
129
158 - /// Delete all files for a variant from disk.
130 Future<void> deleteVariant(KittenTtsModelVariant variant) async {
131 final dir = await _getVariantDir(variant);
161 - for (final file in variant.files) {
162 - final path = '${dir.path}/${file.fileName}';
163 - final f = File(path);
164 - if (await f.exists()) {
165 - await f.delete();
166 - }
167 - final partFile = File('$path.part');
168 - if (await partFile.exists()) {
169 - await partFile.delete();
170 - }
171 - }
172 - if (await dir.exists()) {
173 - final entities = await dir.list().toList();
174 - if (entities.isEmpty) {
175 - await dir.delete();
176 - }
177 - }
132 + await deleteDirectoryIfExists(dir);
133 Log.info('KittenTTS variant ${variant.displayName} deleted');
134 }
135
181 - /// Get list of downloaded variants.
136 Future<Set<KittenTtsModelVariant>> getDownloadedVariants() async {
137 final result = <KittenTtsModelVariant>{};
138 for (final variant in KittenTtsModelVariant.values) {
@@ -189,75 +143,28 @@ class KittenTtsDownloader {
143 return result;
144 }
145
192 - /// Get the absolute file path for a variant's file.
193 - Future<String?> getFilePath(
194 - KittenTtsModelVariant variant,
195 - String fileName,
196 - ) async {
146 + Future<String?> getModelPath(KittenTtsModelVariant variant) async {
147 final dir = await _getVariantDir(variant);
198 - final file = File('${dir.path}/$fileName');
199 - if (await file.exists()) {
200 - return file.path;
201 - }
148 + final file = File('${dir.path}/${variant.modelFileName}');
149 + if (await file.exists()) return file.path;
150 return null;
151 }
152
205 - /// Manually follows redirects to ensure Authorization header safety.
206 - Future<Response<ResponseBody>> _resolveWithRedirects({
207 - required String url,
208 - required int startByte,
209 - required CancelToken cancelToken,
210 - }) async {
211 - String currentUrl = url;
212 - final Uri originalUri = Uri.parse(url);
213 -
214 - for (int hop = 0; hop < 5; hop++) {
215 - final options = Options(
216 - responseType: ResponseType.stream,
217 - followRedirects: false,
218 - validateStatus: (status) => status != null && status < 500,
219 - headers: {
220 - if (startByte > 0) 'Range': 'bytes=$startByte-',
221 - },
222 - );
223 -
224 - final currentUri = Uri.parse(currentUrl);
225 - if (currentUri.host != originalUri.host) {
226 - options.headers?.remove('Authorization');
227 - }
228 -
229 - final response = await _dio.get<ResponseBody>(
230 - currentUrl,
231 - options: options,
232 - cancelToken: cancelToken,
233 - );
234 -
235 - if (response.statusCode == 200 || response.statusCode == 206) {
236 - return response;
237 - }
238 -
239 - if (response.statusCode! >= 300 && response.statusCode! < 400) {
240 - final location = response.headers.value('location');
241 - if (location == null) {
242 - throw DioException(
243 - requestOptions: response.requestOptions,
244 - error: 'Redirect without location header',
245 - );
246 - }
247 - currentUrl = Uri.parse(currentUrl).resolve(location).toString();
248 - continue;
249 - }
153 + Future<String?> getVoicesPath(KittenTtsModelVariant variant) async {
154 + final dir = await _getVariantDir(variant);
155 + final file = File('${dir.path}/${variant.voicesFileName}');
156 + if (await file.exists()) return file.path;
157 + return null;
158 + }
159
251 - throw DioException(
252 - requestOptions: response.requestOptions,
253 - response: response,
254 - error: 'Server returned ${response.statusCode}',
255 - );
256 - }
160 + Future<String?> getTokensPath(KittenTtsModelVariant variant) async {
161 + final dir = await _getVariantDir(variant);
162 + final file = File('${dir.path}/${variant.tokensFileName}');
163 + if (await file.exists()) return file.path;
164 + return null;
165 + }
166
258 - throw DioException(
259 - requestOptions: RequestOptions(path: url),
260 - error: 'Too many redirects',
261 - );
167 + Future<Directory> getDataDir(KittenTtsModelVariant variant) async {
168 + return Directory('${(await _getVariantDir(variant)).path}/${variant.dataDirName}');
169 }
170 }
lib/features/tts/data/kitten_tts_model.dart
+40 -33
@@ -88,7 +88,7 @@ enum KittenTtsModelVariant {
88 }
89
90 /// The ONNX model file name inside the HF repo.
91 - String get modelFileName {
91 + String get sourceModelFileName {
92 switch (this) {
93 case KittenTtsModelVariant.nano:
94 return 'kitten_tts_nano_v0_8.onnx';
@@ -101,19 +101,50 @@ enum KittenTtsModelVariant {
101 }
102 }
103
104 + /// sherpa-onnx bundle directory for this variant.
105 + String get bundleDirName {
106 + switch (this) {
107 + case KittenTtsModelVariant.nano:
108 + return 'kitten-nano-en-v0_1-fp16';
109 + case KittenTtsModelVariant.nanoInt8:
110 + return 'kitten-nano-en-v0_1-fp16';
111 + case KittenTtsModelVariant.micro:
112 + return 'kitten-nano-en-v0_2-fp16';
113 + case KittenTtsModelVariant.mini:
114 + return 'kitten-mini-en-v0_1-fp16';
115 + }
116 + }
117 +
118 + String get tarballUrl {
119 + switch (this) {
120 + case KittenTtsModelVariant.nano:
121 + case KittenTtsModelVariant.nanoInt8:
122 + return 'https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/kitten-nano-en-v0_1-fp16.tar.bz2';
123 + case KittenTtsModelVariant.micro:
124 + return 'https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/kitten-nano-en-v0_2-fp16.tar.bz2';
125 + case KittenTtsModelVariant.mini:
126 + return 'https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/kitten-mini-en-v0_1-fp16.tar.bz2';
127 + }
128 + }
129 +
130 + String get modelFileName => 'model.fp16.onnx';
131 + String get voicesFileName => 'voices.bin';
132 + String get tokensFileName => 'tokens.txt';
133 + String get dataDirName => 'espeak-ng-data';
134 +
135 /// The 3 files required for this variant.
136 List<KittenTtsModelFile> get files {
137 return [
138 KittenTtsModelFile(
108 - fileName: 'config.json',
109 - downloadUrl:
110 - 'https://huggingface.co/$huggingFaceRepoId/resolve/main/config.json',
111 - sizeBytes: 2 * 1024,
112 - ),
113 - KittenTtsModelFile(
114 - fileName: modelFileName,
139 + fileName: 'config.json',
140 + downloadUrl:
141 + 'https://huggingface.co/$huggingFaceRepoId/resolve/main/config.json',
142 + sizeBytes: 2 * 1024,
143 + ),
144 + KittenTtsModelFile(
145 + fileName: sourceModelFileName,
146 downloadUrl:
116 - 'https://huggingface.co/$huggingFaceRepoId/resolve/main/$modelFileName',
147 + 'https://huggingface.co/$huggingFaceRepoId/resolve/main/$sourceModelFileName',
148 sizeBytes: _modelFileSizeBytes,
149 ),
150 KittenTtsModelFile(
@@ -221,27 +252,3 @@ class KittenTtsFileProgress {
252 double get fraction =>
253 totalBytes > 0 ? (receivedBytes / totalBytes).clamp(0.0, 1.0) : 0.0;
254 }
224 -
225 -/// Overall download progress for a variant (all 3 files).
226 -@immutable
227 -class KittenTtsVariantProgress {
228 - final KittenTtsModelVariant variant;
229 - final Map<String, KittenTtsFileProgress> fileProgress;
230 -
231 - const KittenTtsVariantProgress({
232 - required this.variant,
233 - this.fileProgress = const {},
234 - });
235 -
236 - double get overallFraction {
237 - if (fileProgress.isEmpty) return 0.0;
238 - final total = fileProgress.values
239 - .map((f) => f.fraction)
240 - .fold<double>(0.0, (a, b) => a + b);
241 - return total / fileProgress.length;
242 - }
243 -
244 - bool get isComplete =>
245 - fileProgress.isNotEmpty &&
246 - fileProgress.values.every((f) => f.isComplete);
247 -}
lib/features/tts/data/kokoro_tts_downloader.dart deleted
-278
@@ -1,278 +0,0 @@
1 -import 'dart:async';
2 -import 'dart:io';
3 -
4 -import 'package:dio/dio.dart';
5 -import 'package:path_provider/path_provider.dart';
6 -import 'package:wakelock_plus/wakelock_plus.dart';
7 -
8 -import '../../../core/logger/app_logger.dart';
9 -import 'kokoro_tts_model.dart';
10 -
11 -/// Downloads Kokoro TTS model files (kokoro.onnx + voice .bin files)
12 -/// from HuggingFace with resume support and per-file progress reporting.
13 -class KokoroTtsDownloader {
14 - final Dio _dio = Dio();
15 - final Map<String, CancelToken> _activeDownloads = {};
16 -
17 - Future<Directory> _getVariantDir(KokoroTtsModelVariant variant) async {
18 - final supportDir = await getApplicationSupportDirectory();
19 - final dir = Directory('${supportDir.path}/kokoro_tts/${variant.dirName}');
20 - if (!await dir.exists()) {
21 - await dir.create(recursive: true);
22 - }
23 - return dir;
24 - }
25 -
26 - /// Check whether all files for a variant have been downloaded.
27 - Future<bool> isVariantDownloaded(KokoroTtsModelVariant variant) async {
28 - final dir = await _getVariantDir(variant);
29 - final model = KokoroTtsModel.forVariant(variant);
30 -
31 - final modelPath = '${dir.path}/${model.modelFile.fileName}';
32 - if (!await File(modelPath).exists()) return false;
33 -
34 - for (final voice in model.voiceFiles) {
35 - final voicePath = '${dir.path}/${voice.fileName}';
36 - if (!await File(voicePath).exists()) return false;
37 - }
38 - return true;
39 - }
40 -
41 - /// Get total downloaded bytes for a variant.
42 - Future<int> getDownloadedBytes(KokoroTtsModelVariant variant) async {
43 - final dir = await _getVariantDir(variant);
44 - int total = 0;
45 - final entities = await dir.list().toList();
46 - for (final e in entities) {
47 - if (e is File) {
48 - total += await e.length();
49 - }
50 - }
51 - return total;
52 - }
53 -
54 - /// Download all files for [variant], emitting per-file progress.
55 - Stream<KokoroTtsFileProgress> downloadModel(KokoroTtsModelVariant variant) async* {
56 - final model = KokoroTtsModel.forVariant(variant);
57 - final dir = await _getVariantDir(variant);
58 - final variantKey = variant.name;
59 -
60 - await WakelockPlus.enable();
61 -
62 - try {
63 - final allFiles = <_DownloadableFile>[
64 - _DownloadableFile(
65 - fileName: model.modelFile.fileName,
66 - downloadUrl: model.modelFile.downloadUrl,
67 - sizeBytes: model.modelFile.sizeBytes,
68 - ),
69 - for (final voice in model.voiceFiles)
70 - _DownloadableFile(
71 - fileName: voice.fileName,
72 - downloadUrl: voice.downloadUrl,
73 - sizeBytes: voice.sizeBytes,
74 - ),
75 - ];
76 -
77 - for (final file in allFiles) {
78 - final cancelToken = CancelToken();
79 - _activeDownloads[variantKey] = cancelToken;
80 -
81 - final filePath = '${dir.path}/${file.fileName}';
82 - final partialPath = '$filePath.part';
83 - final partialFile = File(partialPath);
84 -
85 - int receivedBytes = 0;
86 -
87 - if (await partialFile.exists()) {
88 - receivedBytes = await partialFile.length();
89 - Log.info(
90 - 'Resuming Kokoro ${variant.displayName} ${file.fileName} from $receivedBytes bytes',
91 - );
92 - }
93 -
94 - DateTime lastProgressUpdate = DateTime.now();
95 -
96 - try {
97 - final response = await _resolveWithRedirects(
98 - url: file.downloadUrl,
99 - startByte: receivedBytes,
100 - cancelToken: cancelToken,
101 - );
102 -
103 - final contentLenHeader =
104 - response.headers.value(Headers.contentLengthHeader);
105 - final totalBytes = contentLenHeader != null
106 - ? int.parse(contentLenHeader) + receivedBytes
107 - : file.sizeBytes;
108 -
109 - final IOSink sink = partialFile.openWrite(mode: FileMode.append);
110 - final stream = response.data?.stream;
111 -
112 - if (stream == null) {
113 - await sink.close();
114 - throw DioException(
115 - requestOptions: response.requestOptions,
116 - error: 'Response data stream is null',
117 - );
118 - }
119 -
120 - await for (final List<int> chunk in stream) {
121 - if (cancelToken.isCancelled) break;
122 - sink.add(chunk);
123 - receivedBytes += chunk.length;
124 -
125 - final now = DateTime.now();
126 - if (now.difference(lastProgressUpdate).inMilliseconds >= 500) {
127 - yield KokoroTtsFileProgress(
128 - fileName: file.fileName,
129 - variant: variant,
130 - receivedBytes: receivedBytes,
131 - totalBytes: totalBytes,
132 - );
133 - lastProgressUpdate = now;
134 - }
135 - }
136 -
137 - await sink.flush();
138 - await sink.close();
139 -
140 - await partialFile.rename(filePath);
141 - Log.info(
142 - 'Kokoro ${variant.displayName} ${file.fileName} downloaded',
143 - );
144 -
145 - yield KokoroTtsFileProgress(
146 - fileName: file.fileName,
147 - variant: variant,
148 - receivedBytes: receivedBytes,
149 - totalBytes: receivedBytes,
150 - isComplete: true,
151 - );
152 - } catch (e) {
153 - if (e is DioException && CancelToken.isCancel(e)) {
154 - Log.info('Kokoro download cancelled for $variantKey');
155 - return;
156 - }
157 - rethrow;
158 - }
159 - }
160 - } finally {
161 - _activeDownloads.remove(variantKey);
162 - if (_activeDownloads.isEmpty) {
163 - await WakelockPlus.disable();
164 - }
165 - }
166 - }
167 -
168 - /// Cancel an in-flight download.
169 - void cancelDownload(KokoroTtsModelVariant variant) {
170 - _activeDownloads[variant.name]?.cancel();
171 - }
172 -
173 - /// Delete all files for a variant from disk.
174 - Future<void> deleteVariant(KokoroTtsModelVariant variant) async {
175 - final dir = await _getVariantDir(variant);
176 - if (await dir.exists()) {
177 - await dir.delete(recursive: true);
178 - }
179 - Log.info('Kokoro variant ${variant.displayName} deleted');
180 - }
181 -
182 - /// Get list of downloaded variants.
183 - Future<Set<KokoroTtsModelVariant>> getDownloadedVariants() async {
184 - final result = <KokoroTtsModelVariant>{};
185 - for (final variant in KokoroTtsModelVariant.values) {
186 - if (await isVariantDownloaded(variant)) {
187 - result.add(variant);
188 - }
189 - }
190 - return result;
191 - }
192 -
193 - /// Get the absolute file path for a variant's model file.
194 - Future<String?> getModelPath(KokoroTtsModelVariant variant) async {
195 - final dir = await _getVariantDir(variant);
196 - final model = KokoroTtsModel.forVariant(variant);
197 - final file = File('${dir.path}/${model.modelFile.fileName}');
198 - if (await file.exists()) return file.path;
199 - return null;
200 - }
201 -
202 - /// Get the directory containing voice .bin files.
203 - Future<Directory> getVoicesDir(KokoroTtsModelVariant variant) async {
204 - return _getVariantDir(variant);
205 - }
206 -
207 - /// Manually follows redirects to ensure Authorization header safety.
208 - Future<Response<ResponseBody>> _resolveWithRedirects({
209 - required String url,
210 - required int startByte,
211 - required CancelToken cancelToken,
212 - }) async {
213 - String currentUrl = url;
214 - final Uri originalUri = Uri.parse(url);
215 -
216 - for (int hop = 0; hop < 5; hop++) {
217 - final options = Options(
218 - responseType: ResponseType.stream,
219 - followRedirects: false,
220 - validateStatus: (status) => status != null && status < 500,
221 - headers: {
222 - if (startByte > 0) 'Range': 'bytes=$startByte-',
223 - },
224 - );
225 -
226 - final currentUri = Uri.parse(currentUrl);
227 - if (currentUri.host != originalUri.host) {
228 - options.headers?.remove('Authorization');
229 - }
230 -
231 - final response = await _dio.get<ResponseBody>(
232 - currentUrl,
233 - options: options,
234 - cancelToken: cancelToken,
235 - );
236 -
237 - if (response.statusCode == 200 || response.statusCode == 206) {
238 - return response;
239 - }
240 -
241 - if (response.statusCode! >= 300 && response.statusCode! < 400) {
242 - final location = response.headers.value('location');
243 - if (location == null) {
244 - throw DioException(
245 - requestOptions: response.requestOptions,
246 - error: 'Redirect without location header',
247 - );
248 - }
249 - currentUrl = Uri.parse(currentUrl).resolve(location).toString();
250 - continue;
251 - }
252 -
253 - throw DioException(
254 - requestOptions: response.requestOptions,
255 - response: response,
256 - error: 'Server returned ${response.statusCode}',
257 - );
258 - }
259 -
260 - throw DioException(
261 - requestOptions: RequestOptions(path: url),
262 - error: 'Too many redirects',
263 - );
264 - }
265 -}
266 -
267 -/// Internal helper for tracking files to download.
268 -class _DownloadableFile {
269 - final String fileName;
270 - final String downloadUrl;
271 - final int sizeBytes;
272 -
273 - const _DownloadableFile({
274 - required this.fileName,
275 - required this.downloadUrl,
276 - required this.sizeBytes,
277 - });
278 -}
lib/features/tts/data/kokoro_tts_model.dart deleted
-181
@@ -1,181 +0,0 @@
1 -import 'package:flutter/material.dart';
2 -
3 -/// Available Kokoro TTS model variants downloadable from HuggingFace.
4 -enum KokoroTtsModelVariant {
5 - /// FP16 precision, ~170 MB total. Best quality.
6 - fp16,
7 -
8 - /// INT8 quantized, ~82 MB total. Faster, smaller.
9 - int8;
10 -
11 - String get displayName {
12 - switch (this) {
13 - case KokoroTtsModelVariant.fp16:
14 - return 'Kokoro TTS (FP16)';
15 - case KokoroTtsModelVariant.int8:
16 - return 'Kokoro TTS (Int8)';
17 - }
18 - }
19 -
20 - String get parameterLabel => '82M';
21 -
22 - /// Approximate total size in bytes.
23 - int get totalSizeBytes {
24 - switch (this) {
25 - case KokoroTtsModelVariant.fp16:
26 - return 170 * 1024 * 1024;
27 - case KokoroTtsModelVariant.int8:
28 - return 82 * 1024 * 1024;
29 - }
30 - }
31 -
32 - String get description {
33 - switch (this) {
34 - case KokoroTtsModelVariant.fp16:
35 - return 'Highest quality with 22 voices. Requires ~250 MB RAM.';
36 - case KokoroTtsModelVariant.int8:
37 - return 'Smaller, faster model with excellent quality. Recommended.';
38 - }
39 - }
40 -
41 - bool get isRecommended => this == KokoroTtsModelVariant.int8;
42 -
43 - String get huggingFaceRepoId {
44 - switch (this) {
45 - case KokoroTtsModelVariant.fp16:
46 - return 'onnx-community/Kokoro-82M-ONNX';
47 - case KokoroTtsModelVariant.int8:
48 - return 'palshub/kokoro-82m-v1.0-int8';
49 - }
50 - }
51 -
52 - /// Storage directory name for this variant.
53 - String get dirName => 'kokoro_$name';
54 -}
55 -
56 -/// Voice metadata for a single Kokoro voice file.
57 -@immutable
58 -class KokoroVoiceFile {
59 - final String voiceId;
60 - final String fileName;
61 - final String downloadUrl;
62 - final int sizeBytes;
63 -
64 - const KokoroVoiceFile({
65 - required this.voiceId,
66 - required this.fileName,
67 - required this.downloadUrl,
68 - required this.sizeBytes,
69 - });
70 -}
71 -
72 -/// Metadata for the main Kokoro model file.
73 -@immutable
74 -class KokoroModelFile {
75 - final String fileName;
76 - final String downloadUrl;
77 - final int sizeBytes;
78 -
79 - const KokoroModelFile({
80 - required this.fileName,
81 - required this.downloadUrl,
82 - required this.sizeBytes,
83 - });
84 -}
85 -
86 -/// A fully-specified Kokoro TTS model with variant, model file, and voices.
87 -@immutable
88 -class KokoroTtsModel {
89 - final KokoroTtsModelVariant variant;
90 - final KokoroModelFile modelFile;
91 - final List<KokoroVoiceFile> voiceFiles;
92 - final int totalSizeBytes;
93 -
94 - const KokoroTtsModel({
95 - required this.variant,
96 - required this.modelFile,
97 - required this.voiceFiles,
98 - required this.totalSizeBytes,
99 - });
100 -
101 - static const List<String> _voiceIds = [
102 - 'af_heart',
103 - 'af_bella',
104 - 'af_nicole',
105 - 'af_aoihana',
106 - 'af_sarah',
107 - 'af_sky',
108 - 'am_adam',
109 - 'am_michael',
110 - 'bf_isabelle',
111 - 'bf_alice',
112 - 'bm_george',
113 - 'bm_lewis',
114 - ];
115 -
116 - static KokoroTtsModel forVariant(KokoroTtsModelVariant variant) {
117 - final repo = variant.huggingFaceRepoId;
118 - final modelPath =
119 - variant == KokoroTtsModelVariant.fp16 ? 'onnx/model.onnx' : 'kokoro.onnx';
120 -
121 - final modelFile = KokoroModelFile(
122 - fileName: 'kokoro.onnx',
123 - downloadUrl:
124 - 'https://huggingface.co/$repo/resolve/main/$modelPath',
125 - sizeBytes: variant == KokoroTtsModelVariant.fp16
126 - ? 166 * 1024 * 1024
127 - : 78 * 1024 * 1024,
128 - );
129 -
130 - final voiceFiles = _voiceIds.map((id) {
131 - final voicePath =
132 - variant == KokoroTtsModelVariant.fp16 ? 'voices/$id.bin' : '$id.bin';
133 - return KokoroVoiceFile(
134 - voiceId: id,
135 - fileName: '$id.bin',
136 - downloadUrl:
137 - 'https://huggingface.co/$repo/resolve/main/$voicePath',
138 - sizeBytes: 175 * 1024,
139 - );
140 - }).toList();
141 -
142 - return KokoroTtsModel(
143 - variant: variant,
144 - modelFile: modelFile,
145 - voiceFiles: voiceFiles,
146 - totalSizeBytes: variant.totalSizeBytes,
147 - );
148 - }
149 -
150 - static final List<KokoroTtsModel> allModels =
151 - KokoroTtsModelVariant.values.map(forVariant).toList();
152 -
153 - String get displayName => variant.displayName;
154 -
155 - String get parameterLabel => variant.parameterLabel;
156 -
157 - String get description => variant.description;
158 -
159 - bool get isRecommended => variant.isRecommended;
160 -}
161 -
162 -/// Progress for downloading a single Kokoro file (model or voice).
163 -@immutable
164 -class KokoroTtsFileProgress {
165 - final String fileName;
166 - final KokoroTtsModelVariant variant;
167 - final int receivedBytes;
168 - final int totalBytes;
169 - final bool isComplete;
170 -
171 - const KokoroTtsFileProgress({
172 - required this.fileName,
173 - required this.variant,
174 - this.receivedBytes = 0,
175 - this.totalBytes = 0,
176 - this.isComplete = false,
177 - });
178 -
179 - double get fraction =>
180 - totalBytes > 0 ? (receivedBytes / totalBytes).clamp(0.0, 1.0) : 0.0;
181 -}
lib/features/tts/data/piper_tts_downloader.dart
+94 -79
@@ -2,9 +2,12 @@ import 'dart:async';
2 import 'dart:io';
3
4 import 'package:dio/dio.dart';
5 +import 'package:path_provider/path_provider.dart';
6 +import 'package:wakelock_plus/wakelock_plus.dart';
7
8 import 'model_downloader.dart';
9 import 'piper_tts_model.dart';
10 +import 'sherpa_bundle_downloader.dart';
11
12 class PiperTtsDownloader {
13 final Dio _dio;
@@ -15,7 +18,7 @@ class PiperTtsDownloader {
18 : _dio = Dio(),
19 _baseDownloader = ModelDownloader();
20
18 - Future<Directory> _getVariantDir(PiperTtsModelVariant variant) async {
21 + Future<Directory> _getEngineDir() async {
22 final ttsDir = await _baseDownloader.getTtsDir();
23 final dir = Directory('${ttsDir.path}/piper');
24 if (!await dir.exists()) {
@@ -24,109 +27,106 @@ class PiperTtsDownloader {
27 return dir;
28 }
29
30 + Future<Directory> _getVariantDir(PiperTtsModelVariant variant) async {
31 + final engineDir = await _getEngineDir();
32 + return Directory('${engineDir.path}/${variant.bundleDirName}');
33 + }
34 +
35 Future<Set<PiperTtsModelVariant>> getDownloadedVariants() async {
36 final downloaded = <PiperTtsModelVariant>{};
37 for (final variant in PiperTtsModelVariant.values) {
38 final dir = await _getVariantDir(variant);
39 final modelFile = File('${dir.path}/${variant.modelFileName}');
32 - final configFile = File('${dir.path}/${variant.configFileName}');
33 - if (await modelFile.exists() && await configFile.exists()) {
40 + final tokensFile = File('${dir.path}/${variant.tokensFileName}');
41 + final dataDir = Directory('${dir.path}/${variant.dataDirName}');
42 + if (await modelFile.exists() && await tokensFile.exists() && await dataDir.exists()) {
43 downloaded.add(variant);
44 }
45 }
46 return downloaded;
47 }
48
40 - Stream<PiperTtsFileProgress> downloadModel(
41 - PiperTtsModelVariant variant,
42 - ) async* {
43 - final cancelToken = CancelToken();
44 - _cancelTokens[variant] = cancelToken;
49 + Future<bool> isVariantDownloaded(PiperTtsModelVariant variant) async {
50 + final dir = await _getVariantDir(variant);
51 + return await File('${dir.path}/${variant.modelFileName}').exists() &&
52 + await File('${dir.path}/${variant.tokensFileName}').exists() &&
53 + await Directory('${dir.path}/${variant.dataDirName}').exists();
54 + }
55
46 - try {
47 - final dir = await _getVariantDir(variant);
56 + Stream<PiperTtsFileProgress> downloadModel(PiperTtsModelVariant variant) {
57 + return Stream<PiperTtsFileProgress>.multi((controller) async {
58 + final cancelToken = CancelToken();
59 + _cancelTokens[variant] = cancelToken;
60 + final engineDir = await _getEngineDir();
61 + await WakelockPlus.enable();
62
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 - }
63 + try {
64 + final tempDir = await getTemporaryDirectory();
65 + final archivePath = '${tempDir.path}/${variant.bundleDirName}.tar.bz2';
66 + final archiveFile = File(archivePath);
67 + final fileName = '${variant.bundleDirName}.tar.bz2';
68 +
69 + controller.add(
70 + PiperTtsFileProgress(
71 + fileName: fileName,
72 + receivedBytes: 0,
73 + totalBytes: variant.totalSizeBytes,
74 + isComplete: false,
75 + ),
76 + );
77
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) {
78 + await _dio.download(
79 + variant.tarballUrl,
80 + archiveFile.path,
81 + cancelToken: cancelToken,
82 + onReceiveProgress: (received, total) {
83 + final totalBytes = total > 0 ? total : variant.totalSizeBytes;
84 controller.add(
85 PiperTtsFileProgress(
86 fileName: fileName,
102 - receivedBytes: receivedBytes,
87 + receivedBytes: received,
88 totalBytes: totalBytes,
89 isComplete: false,
90 ),
91 );
107 - }
108 - },
109 - );
92 + },
93 + );
94 +
95 + if (cancelToken.isCancelled) {
96 + return;
97 + }
98 +
99 + await extractTarBz2(
100 + archivePath: archiveFile.path,
101 + targetDir: engineDir,
102 + );
103 +
104 + if (await archiveFile.exists()) {
105 + await archiveFile.delete();
106 + }
107
111 - if (!controller.isClosed) {
108 controller.add(
109 PiperTtsFileProgress(
110 fileName: fileName,
115 - receivedBytes: receivedBytes,
116 - totalBytes: totalBytes,
111 + receivedBytes: variant.totalSizeBytes,
112 + totalBytes: variant.totalSizeBytes,
113 isComplete: true,
114 ),
115 );
116 + } catch (e, st) {
117 + if (e is DioException && CancelToken.isCancel(e)) {
118 + // Ignore user cancellations.
119 + } else {
120 + controller.addError(e, st);
121 + }
122 + } finally {
123 + _cancelTokens.remove(variant);
124 + if (_cancelTokens.isEmpty) {
125 + await WakelockPlus.disable();
126 + }
127 + await controller.close();
128 }
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;
129 + });
130 }
131
132 void cancelDownload(PiperTtsModelVariant variant) {
@@ -136,9 +136,24 @@ class PiperTtsDownloader {
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();
139 + await deleteDirectoryIfExists(dir);
140 + }
141 +
142 + Future<String?> getModelPath(PiperTtsModelVariant variant) async {
143 + final dir = await _getVariantDir(variant);
144 + final file = File('${dir.path}/${variant.modelFileName}');
145 + if (await file.exists()) return file.path;
146 + return null;
147 + }
148 +
149 + Future<String?> getTokensPath(PiperTtsModelVariant variant) async {
150 + final dir = await _getVariantDir(variant);
151 + final file = File('${dir.path}/${variant.tokensFileName}');
152 + if (await file.exists()) return file.path;
153 + return null;
154 + }
155 +
156 + Future<Directory> getDataDir(PiperTtsModelVariant variant) async {
157 + return Directory('${(await _getVariantDir(variant)).path}/${variant.dataDirName}');
158 }
159 }
lib/features/tts/data/piper_tts_model.dart
+11 -23
@@ -1,8 +1,4 @@
1 -
2 -enum PiperTtsModelVariant {
3 - enUsLessacMedium,
4 - enUsRyanMedium,
5 -}
1 +enum PiperTtsModelVariant { enUsLessacMedium, enUsRyanMedium }
2
3 extension PiperTtsModelVariantExtension on PiperTtsModelVariant {
4 String get id {
@@ -25,33 +21,25 @@ extension PiperTtsModelVariantExtension on PiperTtsModelVariant {
21
22 String get modelFileName => '$id.onnx';
23 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 - }
24 + String get bundleDirName {
25 + return 'vits-piper-$id';
26 }
27
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 - }
28 + String get tarballUrl =>
29 + 'https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/$bundleDirName.tar.bz2';
30 +
31 + String get tokensFileName => 'tokens.txt';
32 + String get dataDirName => 'espeak-ng-data';
33
34 int get totalSizeBytes {
35 switch (this) {
36 case PiperTtsModelVariant.enUsLessacMedium:
50 - return 48 * 1024 * 1024; // ~48MB
37 case PiperTtsModelVariant.enUsRyanMedium:
52 - return 48 * 1024 * 1024; // ~48MB
38 + return 60 * 1024 * 1024;
39 }
40 }
41 +
42 + bool get isRecommended => this == PiperTtsModelVariant.enUsLessacMedium;
43 }
44
45 class PiperTtsFileProgress {
lib/features/tts/data/sherpa_bundle_downloader.dart new
+52
@@ -0,0 +1,52 @@
1 +import 'dart:io';
2 +import 'dart:isolate';
3 +
4 +import 'package:archive/archive.dart';
5 +import 'package:path/path.dart' as p;
6 +
7 +Future<int> directorySize(Directory dir) async {
8 + var total = 0;
9 + if (!await dir.exists()) {
10 + return total;
11 + }
12 +
13 + await for (final entity in dir.list(recursive: true, followLinks: false)) {
14 + if (entity is File) {
15 + total += await entity.length();
16 + }
17 + }
18 + return total;
19 +}
20 +
21 +Future<void> extractTarBz2({
22 + required String archivePath,
23 + required Directory targetDir,
24 +}) async {
25 + await Isolate.run(() => _extractTarBz2Sync(archivePath, targetDir.path));
26 +}
27 +
28 +void _extractTarBz2Sync(String archivePath, String targetDirPath) {
29 + final archiveBytes = File(archivePath).readAsBytesSync();
30 + final tarBytes = BZip2Decoder().decodeBytes(archiveBytes);
31 + final archive = TarDecoder().decodeBytes(tarBytes);
32 +
33 + for (final file in archive) {
34 + final relativePath = file.name;
35 + if (relativePath.isEmpty) continue;
36 +
37 + final outputPath = p.join(targetDirPath, relativePath);
38 + if (file.isFile) {
39 + final outputFile = File(outputPath);
40 + outputFile.createSync(recursive: true);
41 + outputFile.writeAsBytesSync(file.content as List<int>, flush: true);
42 + } else {
43 + Directory(outputPath).createSync(recursive: true);
44 + }
45 + }
46 +}
47 +
48 +Future<void> deleteDirectoryIfExists(Directory dir) async {
49 + if (await dir.exists()) {
50 + await dir.delete(recursive: true);
51 + }
52 +}
lib/features/tts/providers/tts_model_providers.dart
+37 -140
@@ -6,8 +6,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
6 import '../data/model_downloader.dart';
7 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';
9 import '../data/piper_tts_model.dart';
10 import '../data/piper_tts_downloader.dart';
11
@@ -16,11 +14,6 @@ final kittenTtsDownloaderProvider = Provider<KittenTtsDownloader>((ref) {
14 return KittenTtsDownloader();
15 });
16
19 -/// Provider for the Kokoro TTS downloader instance.
20 -final kokoroTtsDownloaderProvider = Provider<KokoroTtsDownloader>((ref) {
21 - return KokoroTtsDownloader();
22 -});
23 -
17 /// Provider for the Piper TTS downloader instance.
18 final piperTtsDownloaderProvider = Provider<PiperTtsDownloader>((ref) {
19 return PiperTtsDownloader();
@@ -35,11 +28,6 @@ final kittenTtsModelsProvider = Provider<List<KittenTtsModel>>((ref) {
28 return KittenTtsModel.allModels;
29 });
30
38 -/// Static list of all available Kokoro TTS models.
39 -final kokoroTtsModelsProvider = Provider<List<KokoroTtsModel>>((ref) {
40 - return KokoroTtsModel.allModels;
41 -});
42 -
31 /// Async provider for the set of downloaded KittenTTS variants.
32 final downloadedKittenTtsVariantsProvider =
33 FutureProvider<Set<KittenTtsModelVariant>>((ref) async {
@@ -47,13 +35,6 @@ final downloadedKittenTtsVariantsProvider =
35 return downloader.getDownloadedVariants();
36 });
37
50 -/// Async provider for the set of downloaded Kokoro variants.
51 -final downloadedKokoroTtsVariantsProvider =
52 - FutureProvider<Set<KokoroTtsModelVariant>>((ref) async {
53 - final downloader = ref.read(kokoroTtsDownloaderProvider);
54 - return downloader.getDownloadedVariants();
55 - });
56 -
38 /// Async provider for the set of downloaded Piper variants.
39 final downloadedPiperTtsVariantsProvider =
40 FutureProvider<Set<PiperTtsModelVariant>>((ref) async {
@@ -92,6 +73,7 @@ class TtsDownloadNotifier
73 /// Start downloading a model variant.
74 Future<void> startDownload(KittenTtsModel model) async {
75 final variant = model.variant;
76 + final completer = Completer<void>();
77
78 final currentVariantProgress = Map<String, KittenTtsFileProgress>.from(
79 state[variant] ?? {},
@@ -115,14 +97,17 @@ class TtsDownloadNotifier
97 onDone: () {
98 _subscriptions.remove(variant);
99 ref.invalidate(downloadedKittenTtsVariantsProvider);
100 + if (!completer.isCompleted) completer.complete();
101 },
102 onError: (e) {
103 _ModelLog.error(
104 'KittenTTS download error for ${variant.displayName}: $e',
105 );
106 _subscriptions.remove(variant);
107 + if (!completer.isCompleted) completer.complete();
108 },
109 );
110 + return completer.future;
111 }
112
113 /// Cancel an in-flight download.
@@ -172,49 +157,51 @@ class TtsDownloadNotifier
157 }
158 }
159
175 -// ── Kokoro TTS Download Progress ──
160 +// ── Piper TTS Download Progress ──
161
177 -/// Notifier that tracks per-file download progress for Kokoro TTS models.
178 -final kokoroTtsDownloadProgressProvider =
162 +/// Notifier that tracks per-file download progress for Piper TTS models.
163 +final piperTtsDownloadProgressProvider =
164 NotifierProvider<
180 - KokoroTtsDownloadNotifier,
181 - Map<KokoroTtsModelVariant, Map<String, KokoroTtsFileProgress>>
182 - >(() => KokoroTtsDownloadNotifier());
165 + PiperTtsDownloadNotifier,
166 + Map<PiperTtsModelVariant, Map<String, PiperTtsFileProgress>>
167 + >(() => PiperTtsDownloadNotifier());
168
184 -class KokoroTtsDownloadNotifier
169 +class PiperTtsDownloadNotifier
170 extends
186 - Notifier<
187 - Map<KokoroTtsModelVariant, Map<String, KokoroTtsFileProgress>>
188 - > {
189 - final Map<KokoroTtsModelVariant, StreamSubscription<KokoroTtsFileProgress>>
171 + Notifier<Map<PiperTtsModelVariant, Map<String, PiperTtsFileProgress>>> {
172 + final Map<PiperTtsModelVariant, StreamSubscription<PiperTtsFileProgress>>
173 _subscriptions = {};
174 + final Map<PiperTtsModelVariant, Completer<void>> _completers = {};
175
176 @override
193 - Map<KokoroTtsModelVariant, Map<String, KokoroTtsFileProgress>> build() {
177 + Map<PiperTtsModelVariant, Map<String, PiperTtsFileProgress>> build() {
178 ref.onDispose(() {
179 for (final sub in _subscriptions.values) {
180 sub.cancel();
181 }
182 _subscriptions.clear();
183 + _completers.clear();
184 });
185 return {};
186 }
187
203 - Future<void> startDownload(KokoroTtsModelVariant variant) async {
204 - final currentVariantProgress = Map<String, KokoroTtsFileProgress>.from(
188 + Future<void> startDownload(PiperTtsModelVariant variant) async {
189 + final completer = Completer<void>();
190 + _completers[variant] = completer;
191 + final currentVariantProgress = Map<String, PiperTtsFileProgress>.from(
192 state[variant] ?? {},
193 );
194
195 state = {...state, variant: currentVariantProgress};
196
210 - final downloader = ref.read(kokoroTtsDownloaderProvider);
197 + final downloader = ref.read(piperTtsDownloaderProvider);
198
199 _subscriptions[variant]?.cancel();
200 _subscriptions[variant] = downloader
201 .downloadModel(variant)
202 .listen(
203 (progress) {
217 - final variantMap = Map<String, KokoroTtsFileProgress>.from(
204 + final variantMap = Map<String, PiperTtsFileProgress>.from(
205 state[variant] ?? {},
206 );
207 variantMap[progress.fileName] = progress;
@@ -222,125 +209,35 @@ class KokoroTtsDownloadNotifier
209 },
210 onDone: () {
211 _subscriptions.remove(variant);
225 - ref.invalidate(downloadedKokoroTtsVariantsProvider);
212 + _completers.remove(variant);
213 + ref.invalidate(downloadedPiperTtsVariantsProvider);
214 + if (!completer.isCompleted) completer.complete();
215 },
216 onError: (e) {
217 _ModelLog.error(
229 - 'Kokoro TTS download error for ${variant.displayName}: $e',
218 + 'Piper TTS download error for ${variant.displayName}: $e',
219 );
220 _subscriptions.remove(variant);
221 + _completers.remove(variant);
222 + if (!completer.isCompleted) completer.complete();
223 },
224 );
234 - }
235 -
236 - void cancelDownload(KokoroTtsModelVariant variant) {
237 - ref.read(kokoroTtsDownloaderProvider).cancelDownload(variant);
238 - _subscriptions[variant]?.cancel();
239 - _subscriptions.remove(variant);
240 -
241 - final newState =
242 - Map<KokoroTtsModelVariant, Map<String, KokoroTtsFileProgress>>.from(
243 - state,
244 - );
245 - newState.remove(variant);
246 - state = newState;
247 - }
248 -
249 - Future<void> deleteVariant(KokoroTtsModelVariant variant) async {
250 - cancelDownload(variant);
251 - await ref.read(kokoroTtsDownloaderProvider).deleteVariant(variant);
252 -
253 - final newState =
254 - Map<KokoroTtsModelVariant, Map<String, KokoroTtsFileProgress>>.from(
255 - state,
256 - );
257 - newState.remove(variant);
258 - state = newState;
259 -
260 - ref.invalidate(downloadedKokoroTtsVariantsProvider);
261 - }
262 -
263 - bool isDownloading(KokoroTtsModelVariant variant) {
264 - final variantProgress = state[variant];
265 - if (variantProgress == null || variantProgress.isEmpty) return false;
266 - return !variantProgress.values.every((f) => f.isComplete);
267 - }
268 -
269 - double getOverallFraction(KokoroTtsModelVariant variant) {
270 - final variantProgress = state[variant];
271 - if (variantProgress == null || variantProgress.isEmpty) return 0.0;
272 - final total = variantProgress.values
273 - .map((f) => f.fraction)
274 - .fold<double>(0.0, (a, b) => a + b);
275 - return total / variantProgress.length;
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 - );
225 + return completer.future;
226 }
227
228 void cancelDownload(PiperTtsModelVariant variant) {
229 ref.read(piperTtsDownloaderProvider).cancelDownload(variant);
230 _subscriptions[variant]?.cancel();
231 _subscriptions.remove(variant);
232 + final completer = _completers.remove(variant);
233 + if (completer != null && !completer.isCompleted) {
234 + completer.complete();
235 + }
236
237 final newState =
238 Map<PiperTtsModelVariant, Map<String, PiperTtsFileProgress>>.from(
342 - state,
343 - );
239 + state,
240 + );
241 newState.remove(variant);
242 state = newState;
243 }
@@ -351,8 +248,8 @@ class PiperTtsDownloadNotifier extends Notifier<
248
249 final newState =
250 Map<PiperTtsModelVariant, Map<String, PiperTtsFileProgress>>.from(
354 - state,
355 - );
251 + state,
252 + );
253 newState.remove(variant);
254 state = newState;
255
lib/features/tts/providers/tts_providers.dart
+88 -108
@@ -1,18 +1,17 @@
1 import 'dart:async';
2 -import 'dart:io';
2 import 'dart:isolate';
3 +import 'dart:typed_data';
4
5 -import 'package:archive/archive.dart';
5 import 'package:audioplayers/audioplayers.dart';
6 import 'package:flutter/services.dart';
7 import 'package:flutter_riverpod/flutter_riverpod.dart';
8 import 'package:flutter_tts/flutter_tts.dart';
10 -import 'package:path_provider/path_provider.dart';
9
10 import '../../../core/logger/app_logger.dart';
11 import '../../../core/models/enums.dart';
12 import '../../../core/providers/app_providers.dart';
13 import 'tts_model_providers.dart';
14 +import '../data/piper_tts_model.dart';
15 import 'tts_worker.dart';
16
17 final ttsProvider = NotifierProvider<TtsNotifier, TtsState>(() {
@@ -60,6 +59,11 @@ class TtsNotifier extends Notifier<TtsState> {
59 int _currentChunkIndex = 0;
60 EngineId? _currentEngine;
61 String? _currentVoice;
62 + String? _currentModelPath;
63 + String? _currentVoicesPath;
64 + String? _currentTokensPath;
65 + String? _currentDataDir;
66 + String? _currentLexicon;
67
68 // Isolate worker fields
69 Isolate? _workerIsolate;
@@ -221,134 +225,104 @@ class TtsNotifier extends Notifier<TtsState> {
225 return chunks;
226 }
227
224 - Future<void> _ensureEspeakData(String targetPath) async {
225 - final dir = Directory(targetPath);
226 - final espeakNgDataDir = Directory('$targetPath/espeak-ng-data');
227 - final voicesDir = Directory('$targetPath/espeak-ng-data/voices');
228 - Log.info('Checking espeak-data at $targetPath');
229 - if (await espeakNgDataDir.exists() && await voicesDir.exists()) {
230 - final entities = await espeakNgDataDir.list().toList();
231 - if (entities.length > 5) {
232 - Log.info('espeak-data looks valid with ${entities.length} entities.');
233 - return;
234 - }
235 - }
236 -
237 - Log.info(
238 - 'espeak-data invalid or missing. (Re)extracting espeak-data.zip to $targetPath...',
239 - );
240 - if (await dir.exists()) {
241 - try {
242 - await dir.delete(recursive: true);
243 - } catch (e) {
244 - Log.error('Failed to clear old espeak-data: $e');
245 - }
246 - }
247 - await dir.create(recursive: true);
248 - try {
249 - final data = await rootBundle.load('assets/espeak-data.zip');
250 - final bytes = data.buffer.asUint8List();
251 -
252 - await Isolate.run(() async {
253 - final archive = ZipDecoder().decodeBytes(bytes);
254 -
255 - for (final file in archive) {
256 - String filename = file.name;
257 - if (filename.startsWith('espeak-data/')) {
258 - filename = filename.substring('espeak-data/'.length);
259 - }
260 - if (filename.isEmpty) continue;
261 -
262 - if (file.isFile) {
263 - final fileData = file.content as List<int>;
264 - final f = File('$targetPath/$filename');
265 - await f.create(recursive: true);
266 - await f.writeAsBytes(fileData);
267 - } else {
268 - await Directory('$targetPath/$filename').create(recursive: true);
269 - }
270 - }
271 - });
272 - Log.info('espeak-data extraction complete.');
273 - } catch (e) {
274 - Log.error('Failed to extract espeak-data: $e');
275 - rethrow;
276 - }
277 - }
278 -
279 - Future<void> _initEngine(EngineId engineId) async {
280 - final supportDir = await getApplicationSupportDirectory();
281 - final resolvedEspeakPath = '${supportDir.path}/espeak-data';
282 -
283 - // Ensure espeak-data is available for neural engines
284 - if (engineId != EngineId.system) {
285 - await _ensureEspeakData(resolvedEspeakPath);
286 - }
287 -
228 + Future<void> _initEngine(EngineId engineId, {String? voiceId}) async {
229 await _ensureWorker();
230
290 - if (_currentEngine == engineId && !_isWorkerInitializing) return;
291 - _isWorkerInitializing = true;
231 + if (_isWorkerInitializing && _initCompleter != null) {
232 + await _initCompleter!.future;
233 + }
234
235 String modelPath = '';
236 String voicesPath = '';
237 + String tokensPath = '';
238 + String dataDir = '';
239 + String lexicon = '';
240
241 switch (engineId) {
242 case EngineId.kitten:
243 final variant = ref.read(settingsProvider).kittenTtsModelVariant;
244 final kittenDl = ref.read(kittenTtsDownloaderProvider);
300 - final mPath = await kittenDl.getFilePath(
301 - variant,
302 - variant.modelFileName,
303 - );
304 - final vPath = await kittenDl.getFilePath(variant, 'voices.npz');
305 - if (mPath == null || vPath == null) {
245 + final mPath = await kittenDl.getModelPath(variant);
246 + final vPath = await kittenDl.getVoicesPath(variant);
247 + final tPath = await kittenDl.getTokensPath(variant);
248 + final dDir = await kittenDl.getDataDir(variant);
249 + if (mPath == null || vPath == null || tPath == null) {
250 throw StateError(
251 'Kitten model files not found for ${variant.name}. Please download them in the Model Manager.',
252 );
253 }
254 modelPath = mPath;
255 voicesPath = vPath;
256 + tokensPath = tPath;
257 + dataDir = dDir.path;
258 break;
313 - case EngineId.kokoro:
314 - final variant = ref.read(settingsProvider).kokoroTtsModelVariant;
315 - final kokoroDl = ref.read(kokoroTtsDownloaderProvider);
316 - final mPath = await kokoroDl.getModelPath(variant);
317 - final vDir = await kokoroDl.getVoicesDir(variant);
318 - if (mPath == null) {
259 + case EngineId.piper:
260 + final selectedVoiceId =
261 + voiceId ??
262 + ref.read(settingsProvider).ttsVoiceId ??
263 + piperVoices.first.id;
264 + final requestedVariant = PiperTtsModelVariant.values.firstWhere(
265 + (v) => v.id == selectedVoiceId,
266 + orElse: () => PiperTtsModelVariant.enUsLessacMedium,
267 + );
268 + final downloader = ref.read(piperTtsDownloaderProvider);
269 + final downloadedVariants = await downloader.getDownloadedVariants();
270 + final variant = downloadedVariants.contains(requestedVariant)
271 + ? requestedVariant
272 + : downloadedVariants.isNotEmpty
273 + ? downloadedVariants.first
274 + : requestedVariant;
275 + final mPath = await downloader.getModelPath(variant);
276 + final tPath = await downloader.getTokensPath(variant);
277 + final dDir = await downloader.getDataDir(variant);
278 + if (mPath == null || tPath == null) {
279 throw StateError(
320 - 'Kokoro model files not found for ${variant.name}. Please download them in the Model Manager.',
280 + 'Piper voice files not found for ${variant.displayName}. Please download them in the Model Manager.',
281 );
282 }
283 modelPath = mPath;
324 - voicesPath = vDir.path;
325 - break;
326 - case EngineId.piper:
327 - final downloader = ref.read(modelDownloaderProvider);
328 - final ttsDir = await downloader.getTtsDir();
329 - final piperDir = '${ttsDir.path}/piper';
330 - if (!await Directory(piperDir).exists()) {
331 - throw StateError('Piper models directory not found.');
332 - }
333 - modelPath = piperDir;
284 + tokensPath = tPath;
285 + dataDir = dDir.path;
286 break;
287 default:
288 return;
289 }
290
339 - _initCompleter = Completer<void>();
340 - _workerSendPort!.send(
341 - TtsInitRequest(
342 - engineId: engineId,
343 - modelPath: modelPath,
344 - voicesPath: voicesPath,
345 - espeakPath: resolvedEspeakPath,
346 - ),
347 - );
291 + if (_currentEngine == engineId &&
292 + !_isWorkerInitializing &&
293 + _currentModelPath == modelPath &&
294 + _currentVoicesPath == voicesPath &&
295 + _currentTokensPath == tokensPath &&
296 + _currentDataDir == dataDir &&
297 + _currentLexicon == lexicon) {
298 + return;
299 + }
300 +
301 + _isWorkerInitializing = true;
302
349 - await _initCompleter!.future;
350 - _currentEngine = engineId;
351 - _isWorkerInitializing = false;
303 + try {
304 + _initCompleter = Completer<void>();
305 + _workerSendPort!.send(
306 + TtsInitRequest(
307 + engineId: engineId,
308 + modelPath: modelPath,
309 + voicesPath: voicesPath,
310 + tokensPath: tokensPath,
311 + dataDir: dataDir,
312 + lexicon: lexicon,
313 + ),
314 + );
315 +
316 + await _initCompleter!.future;
317 + _currentEngine = engineId;
318 + _currentModelPath = modelPath;
319 + _currentVoicesPath = voicesPath;
320 + _currentTokensPath = tokensPath;
321 + _currentDataDir = dataDir;
322 + _currentLexicon = lexicon;
323 + } finally {
324 + _isWorkerInitializing = false;
325 + }
326 }
327
328 Future<void> speak(String text, {EngineId? engineId, String? voiceId}) async {
@@ -365,6 +339,12 @@ class TtsNotifier extends Notifier<TtsState> {
339 final settings = ref.read(settingsProvider);
340 engineId ??= settings.ttsEngine;
341
342 + final availableVoices = voicesForEngine(engineId);
343 + final resolvedVoiceId =
344 + voiceId ??
345 + settings.ttsVoiceId ??
346 + (availableVoices.isNotEmpty ? availableVoices.first.id : null);
347 +
348 if (engineId == EngineId.system) {
349 _flutterTts ??= FlutterTts();
350 state = state.copyWith(
@@ -396,7 +376,7 @@ class TtsNotifier extends Notifier<TtsState> {
376
377 _chunks = _splitText(text);
378 _currentChunkIndex = 0;
399 - _currentVoice = voiceId;
379 + _currentVoice = resolvedVoiceId;
380 _synthesizedChunks.clear();
381 _lastEnqueuedChunk = -1;
382 _currentSessionId++;
@@ -410,7 +390,7 @@ class TtsNotifier extends Notifier<TtsState> {
390 );
391
392 try {
413 - await _initEngine(engineId);
393 + await _initEngine(engineId, voiceId: resolvedVoiceId);
394 state = state.copyWith(isInitializing: false);
395 await _playNextChunk();
396 } catch (e, st) {
@@ -462,7 +442,7 @@ class TtsNotifier extends Notifier<TtsState> {
442 final voiceId =
443 _currentVoice ??
444 settings.ttsVoiceId ??
465 - (availableVoices.isNotEmpty ? availableVoices.first.id : 'Luna');
445 + (availableVoices.isNotEmpty ? availableVoices.first.id : '');
446
447 while (_lastEnqueuedChunk < _chunks.length - 1 &&
448 _lastEnqueuedChunk < _currentChunkIndex + _lookAheadCount) {
@@ -503,4 +483,4 @@ class TtsNotifier extends Notifier<TtsState> {
483 }
484 }
485
506 -const ttsPreviewSample = "Hello, this is a preview of the selected voice.";
486 +const ttsPreviewSample = "Hello, this is a preview of the selected voice";
lib/features/tts/providers/tts_worker.dart
+101 -92
@@ -1,6 +1,8 @@
1 import 'dart:isolate';
2 import 'dart:typed_data';
3 -import 'package:meomeo/meomeo.dart';
3 +
4 +import 'package:sherpa_onnx/sherpa_onnx.dart' as sherpa_onnx;
5 +
6 import '../../../core/logger/app_logger.dart';
7 import '../../../core/models/enums.dart';
8
@@ -10,13 +12,17 @@ class TtsInitRequest extends TtsWorkerMessage {
12 final EngineId engineId;
13 final String modelPath;
14 final String voicesPath;
13 - final String espeakPath;
15 + final String tokensPath;
16 + final String dataDir;
17 + final String lexicon;
18
19 TtsInitRequest({
20 required this.engineId,
21 required this.modelPath,
22 required this.voicesPath,
19 - required this.espeakPath,
23 + required this.tokensPath,
24 + required this.dataDir,
25 + required this.lexicon,
26 });
27 }
28
@@ -58,54 +64,26 @@ void ttsWorkerEntry(SendPort mainSendPort) {
64 final receivePort = ReceivePort();
65 mainSendPort.send(receivePort.sendPort);
66
61 - MeoKitten? kitten;
62 - MeoKokoro? kokoro;
63 - MeoPiper? piper;
67 + sherpa_onnx.initBindings();
68 +
69 + sherpa_onnx.OfflineTts? tts;
70 EngineId? activeEngineId;
71 + TtsInitRequest? activeInit;
72
73 receivePort.listen((message) async {
74 try {
75 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;
76 + tts?.free();
77 + tts = 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 - }
80 + activeInit = message;
81 +
82 + tts = _createTts(message);
83 Log.info('Worker initialized engine: ${message.engineId}');
84 mainSendPort.send(TtsWorkerResponse(isInitResponse: true));
85 } else if (message is TtsSynthesizeRequest) {
108 - if (activeEngineId == null) {
86 + if (activeEngineId == null || tts == null || activeInit == null) {
87 mainSendPort.send(TtsWorkerResponse(
88 chunkIndex: message.chunkIndex,
89 sessionId: message.sessionId,
@@ -114,44 +92,31 @@ void ttsWorkerEntry(SendPort mainSendPort) {
92 return;
93 }
94
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 - }
95 + final speakerId = _resolveSpeakerId(activeEngineId!, message.voiceId);
96 + final genConfig = sherpa_onnx.OfflineTtsGenerationConfig(
97 + sid: speakerId,
98 + speed: message.speed,
99 + silenceScale: 0.2,
100 + );
101
135 - if (pcm != null && pcm.isNotEmpty) {
136 - Log.debug('Worker synthesis success: ${pcm.length} samples');
137 - final wavBytes = _createWav(pcm, sampleRate);
102 + final audio = tts!.generateWithConfig(text: message.text, config: genConfig);
103 + if (audio.samples.isEmpty) {
104 mainSendPort.send(TtsWorkerResponse(
105 chunkIndex: message.chunkIndex,
106 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,
107 + error: 'Empty PCM generated',
108 ));
109 + return;
110 }
111 +
112 + final wavBytes = _createWav(audio.samples, audio.sampleRate);
113 + mainSendPort.send(TtsWorkerResponse(
114 + chunkIndex: message.chunkIndex,
115 + sessionId: message.sessionId,
116 + wavBytes: wavBytes,
117 + ));
118 } 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.
119 + // No-op: the main isolate handles queue cancellation.
120 }
121 } catch (e) {
122 mainSendPort.send(TtsWorkerResponse(error: e.toString()));
@@ -159,39 +124,83 @@ void ttsWorkerEntry(SendPort mainSendPort) {
124 });
125 }
126
127 +sherpa_onnx.OfflineTts _createTts(TtsInitRequest message) {
128 + final vits = message.engineId == EngineId.piper
129 + ? sherpa_onnx.OfflineTtsVitsModelConfig(
130 + model: message.modelPath,
131 + tokens: message.tokensPath,
132 + dataDir: message.dataDir,
133 + lexicon: message.lexicon,
134 + )
135 + : sherpa_onnx.OfflineTtsVitsModelConfig();
136 +
137 + final kitten = message.engineId == EngineId.kitten
138 + ? sherpa_onnx.OfflineTtsKittenModelConfig(
139 + model: message.modelPath,
140 + voices: message.voicesPath,
141 + tokens: message.tokensPath,
142 + dataDir: message.dataDir,
143 + )
144 + : sherpa_onnx.OfflineTtsKittenModelConfig();
145 +
146 + final modelConfig = sherpa_onnx.OfflineTtsModelConfig(
147 + vits: vits,
148 + kitten: kitten,
149 + numThreads: 2,
150 + debug: true,
151 + provider: 'cpu',
152 + );
153 +
154 + final config = sherpa_onnx.OfflineTtsConfig(
155 + model: modelConfig,
156 + maxNumSenetences: 1,
157 + );
158 +
159 + return sherpa_onnx.OfflineTts(config);
160 +}
161 +
162 +int _resolveSpeakerId(EngineId engine, String voiceId) {
163 + if (engine == EngineId.piper) {
164 + return 0;
165 + }
166 + final voices = voicesForEngine(engine);
167 + final index = voices.indexWhere((voice) => voice.id == voiceId);
168 + return index < 0 ? 0 : index;
169 +}
170 +
171 Uint8List _createWav(Float32List samples, int sampleRate) {
172 final int byteCount = samples.length * 2;
173 final int totalSize = 36 + byteCount;
174 final Uint8List wav = Uint8List(44 + byteCount);
175 final ByteData data = ByteData.view(wav.buffer);
176
168 - data.setUint8(0, 0x52); // R
169 - data.setUint8(1, 0x49); // I
170 - data.setUint8(2, 0x46); // F
171 - data.setUint8(3, 0x46); // F
177 + data.setUint8(0, 0x52);
178 + data.setUint8(1, 0x49);
179 + data.setUint8(2, 0x46);
180 + data.setUint8(3, 0x46);
181 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
182 + data.setUint8(8, 0x57);
183 + data.setUint8(9, 0x41);
184 + data.setUint8(10, 0x56);
185 + data.setUint8(11, 0x45);
186 + data.setUint8(12, 0x66);
187 + data.setUint8(13, 0x6d);
188 + data.setUint8(14, 0x74);
189 + data.setUint8(15, 0x20);
190 data.setUint32(16, 16, Endian.little);
182 - data.setUint16(20, 1, Endian.little); // PCM
183 - data.setUint16(22, 1, Endian.little); // Mono
191 + data.setUint16(20, 1, Endian.little);
192 + data.setUint16(22, 1, Endian.little);
193 data.setUint32(24, sampleRate, Endian.little);
194 data.setUint32(28, sampleRate * 2, Endian.little);
195 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
196 + data.setUint16(34, 16, Endian.little);
197 + data.setUint8(36, 0x64);
198 + data.setUint8(37, 0x61);
199 + data.setUint8(38, 0x74);
200 + data.setUint8(39, 0x61);
201 data.setUint32(40, byteCount, Endian.little);
202
194 - for (int i = 0; i < samples.length; i++) {
203 + for (var i = 0; i < samples.length; i++) {
204 final int sample = (samples[i] * 32767).clamp(-32768, 32767).toInt();
205 data.setInt16(44 + (i * 2), sample, Endian.little);
206 }
lib/features/tts/views/tts_model_manager_screen.dart
+171 -245
@@ -9,33 +9,10 @@ import '../../../core/providers/app_providers.dart';
9 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';
12 import '../data/piper_tts_model.dart';
13 import '../providers/tts_model_providers.dart';
14 import '../providers/tts_providers.dart' as tts;
15
17 -/// Async provider for the set of installed engine IDs.
18 -final installedEnginesProvider = FutureProvider<Set<EngineId>>((ref) async {
19 - final downloader = ref.read(modelDownloaderProvider);
20 - final kittenDownloader = ref.read(kittenTtsDownloaderProvider);
21 - final kokoroDownloader = ref.read(kokoroTtsDownloaderProvider);
22 - final result = <EngineId>{};
23 - result.add(EngineId.system);
24 - final downloadedVariants = await kittenDownloader.getDownloadedVariants();
25 - if (downloadedVariants.isNotEmpty) {
26 - result.add(EngineId.kitten);
27 - }
28 - final downloadedKokoroVariants = await kokoroDownloader
29 - .getDownloadedVariants();
30 - if (downloadedKokoroVariants.isNotEmpty) {
31 - result.add(EngineId.kokoro);
32 - }
33 - if (await downloader.isEngineDownloaded(EngineId.piper)) {
34 - result.add(EngineId.piper);
35 - }
36 - return result;
37 -});
38 -
16 class TtsModelManagerScreen extends ConsumerWidget {
17 const TtsModelManagerScreen({super.key});
18
@@ -53,8 +30,6 @@ class TtsModelManagerScreen extends ConsumerWidget {
30 const SizedBox(height: 16),
31 _KittenEngineCard(isSelected: settings.ttsEngine == EngineId.kitten),
32 const SizedBox(height: 16),
56 - _KokoroEngineCard(isSelected: settings.ttsEngine == EngineId.kokoro),
57 - const SizedBox(height: 16),
33 _PiperEngineCard(isSelected: settings.ttsEngine == EngineId.piper),
34 const SizedBox(height: 24),
35 ],
@@ -274,194 +249,15 @@ class _KittenEngineCard extends ConsumerWidget {
249 .getOverallFraction(model.variant);
250 return Column(
251 children: [
277 - LinearProgressIndicator(value: fraction),
278 - const SizedBox(height: 4),
279 - if (progress != null)
280 - ...progress.entries.map(
281 - (e) => Padding(
282 - padding: const EdgeInsets.only(bottom: 2),
283 - child: Row(
284 - children: [
285 - Expanded(
286 - child: Text(
287 - e.key,
288 - style: Theme.of(context).textTheme.labelSmall,
289 - ),
290 - ),
291 - Text(
292 - '${(e.value.fraction * 100).toStringAsFixed(0)}%',
293 - style: Theme.of(context).textTheme.labelSmall,
294 - ),
295 - ],
296 - ),
297 - ),
298 - ),
299 - ],
300 - );
301 - }
302 -}
303 -
304 -// ── Kokoro Engine ──
305 -
306 -class _KokoroEngineCard extends ConsumerWidget {
307 - final bool isSelected;
308 -
309 - const _KokoroEngineCard({required this.isSelected});
310 -
311 - KokoroTtsModel _selectedModel(AppSettings settings) {
312 - final variant = settings.kokoroTtsModelVariant;
313 - return KokoroTtsModel.forVariant(variant);
314 - }
315 -
316 - @override
317 - Widget build(BuildContext context, WidgetRef ref) {
318 - final settings = ref.watch(settingsProvider);
319 - final meta = EngineMeta.kokoro;
320 - final downloadedAsync = ref.watch(downloadedKokoroTtsVariantsProvider);
321 - final downloadProgress = ref.watch(kokoroTtsDownloadProgressProvider);
322 - final model = _selectedModel(settings);
323 - final variantProgress = downloadProgress[model.variant];
324 - final isInstalled = downloadedAsync.when(
325 - data: (set) => set.contains(model.variant),
326 - loading: () => false,
327 - error: (_, _) => false,
328 - );
329 - final notifier = ref.read(kokoroTtsDownloadProgressProvider.notifier);
330 - final isDownloading = notifier.isDownloading(model.variant);
331 -
332 - return _EngineCard(
333 - isSelected: isSelected,
334 - meta: meta,
335 - engine: EngineId.kokoro,
336 - installed: isInstalled,
337 - statusText: isInstalled
338 - ? 'Installed'
339 - : isDownloading
340 - ? 'Downloading...'
341 - : 'Not installed',
342 - trailing: _buildAction(context, ref, isInstalled, isDownloading, model),
343 - child: Column(
344 - crossAxisAlignment: CrossAxisAlignment.start,
345 - children: [
346 - Padding(
347 - padding: const EdgeInsets.only(top: 12),
348 - child: Text(
349 - 'Kokoro 82M parameter model with 22 expressive voices.\n'
350 - 'Requires ${_formatSize(model.totalSizeBytes)} download.',
351 - style: Theme.of(context).textTheme.bodySmall?.copyWith(
352 - color: Theme.of(
353 - context,
354 - ).colorScheme.onSurface.withValues(alpha: 0.6),
355 - ),
356 - ),
357 - ),
358 - if (isDownloading) ...[
359 - const SizedBox(height: 12),
360 - _buildVariantDownloadProgress(context, ref, model, variantProgress),
361 - ],
362 - if (isInstalled) ...[
363 - _VoiceChips(voices: kokoroVoices, engine: EngineId.kokoro),
364 - ],
365 - ],
366 - ),
367 - );
368 - }
369 -
370 - Widget _buildAction(
371 - BuildContext context,
372 - WidgetRef ref,
373 - bool isInstalled,
374 - bool isDownloading,
375 - KokoroTtsModel model,
376 - ) {
377 - final notifier = ref.read(kokoroTtsDownloadProgressProvider.notifier);
378 - if (isDownloading) {
379 - return ShadButton.outline(
380 - size: ShadButtonSize.sm,
381 - onPressed: () => notifier.cancelDownload(model.variant),
382 - child: const Text('Cancel'),
383 - );
384 - }
385 - if (isInstalled) {
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(
407 - size: ShadButtonSize.sm,
408 - onPressed: () => notifier.startDownload(model.variant),
409 - child: const Text('Install'),
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(),
252 + LinearProgressIndicator(
253 + value: fraction,
254 + valueColor: AlwaysStoppedAnimation<Color>(
255 + Color(EngineMeta.kitten.accentColor),
256 ),
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.'),
257 + backgroundColor: Color(
258 + EngineMeta.kitten.accentColor,
259 + ).withValues(alpha: 0.15),
260 ),
444 - ),
445 - );
446 - }
447 -
448 - String _formatSize(int bytes) {
449 - final mb = bytes / (1024 * 1024);
450 - return '${mb.toStringAsFixed(0)} MB';
451 - }
452 -
453 - Widget _buildVariantDownloadProgress(
454 - BuildContext context,
455 - WidgetRef ref,
456 - KokoroTtsModel model,
457 - Map<String, KokoroTtsFileProgress>? progress,
458 - ) {
459 - final fraction = ref
460 - .read(kokoroTtsDownloadProgressProvider.notifier)
461 - .getOverallFraction(model.variant);
462 - return Column(
463 - children: [
464 - LinearProgressIndicator(value: fraction),
261 const SizedBox(height: 4),
262 if (progress != null)
263 ...progress.entries.map(
@@ -495,45 +291,68 @@ class _PiperEngineCard extends ConsumerWidget {
291
292 const _PiperEngineCard({required this.isSelected});
293
294 + PiperTtsModelVariant _selectedVariant(AppSettings settings) {
295 + final voiceId =
296 + voiceFromSettings(settings.ttsVoiceId, EngineId.piper)?.id ??
297 + piperVoices.first.id;
298 + return PiperTtsModelVariant.values.firstWhere(
299 + (variant) => variant.id == voiceId,
300 + orElse: () => PiperTtsModelVariant.enUsLessacMedium,
301 + );
302 + }
303 +
304 @override
305 Widget build(BuildContext context, WidgetRef ref) {
306 + final settings = ref.watch(settingsProvider);
307 final meta = EngineMeta.piper;
308 final downloadedAsync = ref.watch(downloadedPiperTtsVariantsProvider);
309 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 - );
310 final notifier = ref.read(piperTtsDownloadProgressProvider.notifier);
516 - final isDownloading = notifier.isDownloading(variant);
311 + final selectedVariant = _selectedVariant(settings);
312 + final selectedVoice = piperVoices.firstWhere(
313 + (voice) => voice.id == selectedVariant.id,
314 + orElse: () => piperVoices.first,
315 + );
316 + final downloadedSet = downloadedAsync.when(
317 + data: (set) => set,
318 + loading: () => <PiperTtsModelVariant>{},
319 + error: (_, _) => <PiperTtsModelVariant>{},
320 + );
321 + final installedVoices = piperVoices
322 + .where(
323 + (voice) => downloadedSet.any((variant) => variant.id == voice.id),
324 + )
325 + .toList();
326 + final variantProgress = downloadProgress[selectedVariant];
327 + final isInstalled = downloadedSet.contains(selectedVariant);
328 + final isDownloading = notifier.isDownloading(selectedVariant);
329
330 return _EngineCard(
331 isSelected: isSelected,
332 meta: meta,
333 engine: EngineId.piper,
522 - installed: isInstalled,
334 + installed: installedVoices.isNotEmpty,
335 statusText: isInstalled
336 ? 'Installed'
337 : isDownloading
338 ? 'Downloading...'
339 : 'Not installed',
528 - trailing: _buildAction(context, ref, isInstalled, isDownloading, variant),
340 + trailing: _buildAction(
341 + context,
342 + ref,
343 + selectedVariant,
344 + selectedVoice,
345 + isInstalled,
346 + isDownloading,
347 + ),
348 child: Column(
349 crossAxisAlignment: CrossAxisAlignment.start,
350 children: [
351 Padding(
352 padding: const EdgeInsets.only(top: 12),
353 child: Text(
535 - 'One ONNX model per voice. 30+ languages, hundreds of voices.\n'
536 - 'Requires ~${meta.sizeMb} MB download for the default voice.',
354 + 'Fast offline Piper voices with 2 expressive voices.\n'
355 + 'Requires ${_formatSize(selectedVariant.totalSizeBytes)} download per voice.',
356 style: Theme.of(context).textTheme.bodySmall?.copyWith(
357 color: Theme.of(
358 context,
@@ -541,17 +360,38 @@ class _PiperEngineCard extends ConsumerWidget {
360 ),
361 ),
362 ),
363 + const SizedBox(height: 12),
364 + Wrap(
365 + spacing: 8,
366 + runSpacing: 8,
367 + children: PiperTtsModelVariant.values
368 + .map(
369 + (variant) => _PiperVariantChip(
370 + variant: variant,
371 + voice: piperVoices.firstWhere(
372 + (voice) => voice.id == variant.id,
373 + orElse: () => selectedVoice,
374 + ),
375 + selected: selectedVariant == variant,
376 + installed: downloadedSet.contains(variant),
377 + onTap: () => ref
378 + .read(settingsProvider.notifier)
379 + .setTtsVoiceId(variant.id),
380 + ),
381 + )
382 + .toList(),
383 + ),
384 if (isDownloading) ...[
385 const SizedBox(height: 12),
386 _buildVariantDownloadProgress(
387 context,
388 ref,
549 - variant,
389 + selectedVariant,
390 variantProgress,
391 ),
392 ],
553 - if (isInstalled) ...[
554 - _VoiceChips(voices: piperVoices, engine: EngineId.piper),
393 + if (installedVoices.isNotEmpty) ...[
394 + _VoiceChips(voices: installedVoices, engine: EngineId.piper),
395 ],
396 ],
397 ),
@@ -561,9 +401,10 @@ class _PiperEngineCard extends ConsumerWidget {
401 Widget _buildAction(
402 BuildContext context,
403 WidgetRef ref,
404 + PiperTtsModelVariant variant,
405 + Voice voice,
406 bool isInstalled,
407 bool isDownloading,
566 - PiperTtsModelVariant variant,
408 ) {
409 final notifier = ref.read(piperTtsDownloadProgressProvider.notifier);
410 if (isDownloading) {
@@ -580,9 +421,12 @@ class _PiperEngineCard extends ConsumerWidget {
421 if (!isSelected)
422 ShadButton.outline(
423 size: ShadButtonSize.sm,
583 - onPressed: () => ref
584 - .read(settingsProvider.notifier)
585 - .setTtsEngine(EngineId.piper),
424 + onPressed: () {
425 + ref.read(settingsProvider.notifier).setTtsVoiceId(voice.id);
426 + ref
427 + .read(settingsProvider.notifier)
428 + .setTtsEngine(EngineId.piper);
429 + },
430 child: const Text('Select'),
431 ),
432 const SizedBox(width: 8),
@@ -609,9 +453,9 @@ class _PiperEngineCard extends ConsumerWidget {
453 showShadDialog(
454 context: context,
455 builder: (context) => ShadDialog(
612 - title: const Text('Delete Model'),
456 + title: const Text('Delete Voice'),
457 description: Text(
614 - 'Are you sure you want to delete ${variant.displayName}? This will free up storage space on your device.',
458 + 'Are you sure you want to delete ${variant.displayName}? This will free up approximately ${_formatSize(variant.totalSizeBytes)} of space.',
459 ),
460 actions: [
461 ShadButton.outline(
@@ -630,12 +474,17 @@ class _PiperEngineCard extends ConsumerWidget {
474 ],
475 child: const Padding(
476 padding: EdgeInsets.symmetric(vertical: 20),
633 - child: Text('You can download this model again later if needed.'),
477 + child: Text('You can download this voice again later if needed.'),
478 ),
479 ),
480 );
481 }
482
483 + String _formatSize(int bytes) {
484 + final mb = bytes / (1024 * 1024);
485 + return '${mb.toStringAsFixed(0)} MB';
486 + }
487 +
488 Widget _buildVariantDownloadProgress(
489 BuildContext context,
490 WidgetRef ref,
@@ -647,7 +496,15 @@ class _PiperEngineCard extends ConsumerWidget {
496 .getOverallFraction(variant);
497 return Column(
498 children: [
650 - LinearProgressIndicator(value: fraction),
499 + LinearProgressIndicator(
500 + value: fraction,
501 + valueColor: AlwaysStoppedAnimation<Color>(
502 + Color(EngineMeta.piper.accentColor),
503 + ),
504 + backgroundColor: Color(
505 + EngineMeta.piper.accentColor,
506 + ).withValues(alpha: 0.15),
507 + ),
508 const SizedBox(height: 4),
509 if (progress != null)
510 ...progress.entries.map(
@@ -674,6 +531,75 @@ class _PiperEngineCard extends ConsumerWidget {
531 }
532 }
533
534 +class _PiperVariantChip extends StatelessWidget {
535 + final PiperTtsModelVariant variant;
536 + final Voice voice;
537 + final bool selected;
538 + final bool installed;
539 + final VoidCallback onTap;
540 +
541 + const _PiperVariantChip({
542 + required this.variant,
543 + required this.voice,
544 + required this.selected,
545 + required this.installed,
546 + required this.onTap,
547 + });
548 +
549 + @override
550 + Widget build(BuildContext context) {
551 + final theme = Theme.of(context);
552 + final accent = Color(EngineMeta.piper.accentColor);
553 +
554 + return InkWell(
555 + onTap: onTap,
556 + borderRadius: BorderRadius.circular(999),
557 + child: Container(
558 + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
559 + decoration: BoxDecoration(
560 + color: selected
561 + ? accent.withValues(alpha: 0.12)
562 + : theme.colorScheme.surfaceContainerHighest.withValues(
563 + alpha: 0.2,
564 + ),
565 + borderRadius: BorderRadius.circular(999),
566 + border: Border.all(
567 + color: selected
568 + ? accent.withValues(alpha: 0.45)
569 + : installed
570 + ? Colors.green.withValues(alpha: 0.35)
571 + : theme.colorScheme.outline.withValues(alpha: 0.2),
572 + ),
573 + ),
574 + child: Row(
575 + mainAxisSize: MainAxisSize.min,
576 + children: [
577 + Text(
578 + voice.name,
579 + style: theme.textTheme.labelSmall?.copyWith(
580 + color: selected ? accent : theme.colorScheme.onSurface,
581 + fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
582 + ),
583 + ),
584 + const SizedBox(width: 6),
585 + Text(
586 + variant.id.contains('ryan') ? 'Male' : 'Female',
587 + style: theme.textTheme.labelSmall?.copyWith(
588 + color: theme.colorScheme.onSurface.withValues(alpha: 0.5),
589 + fontWeight: FontWeight.w500,
590 + ),
591 + ),
592 + if (installed) ...[
593 + const SizedBox(width: 6),
594 + const Icon(Icons.check_circle, size: 14, color: Colors.green),
595 + ],
596 + ],
597 + ),
598 + ),
599 + );
600 + }
601 +}
602 +
603 // ── Shared Widgets ──
604
605 class _EngineCard extends StatelessWidget {
@@ -841,9 +767,9 @@ class _VoiceChipsState extends ConsumerState<_VoiceChips> {
767 child: Column(
768 crossAxisAlignment: CrossAxisAlignment.start,
769 children: [
844 - if (females.isNotEmpty) ...[
770 + if (males.isNotEmpty) ...[
771 Text(
846 - 'Female',
772 + 'Male',
773 style: theme.textTheme.labelSmall?.copyWith(
774 color: theme.colorScheme.onSurface.withValues(alpha: 0.5),
775 ),
@@ -852,7 +778,7 @@ class _VoiceChipsState extends ConsumerState<_VoiceChips> {
778 SingleChildScrollView(
779 scrollDirection: Axis.horizontal,
780 child: Row(
855 - children: females
781 + children: males
782 .map(
783 (v) => Padding(
784 padding: const EdgeInsets.only(right: 8),
@@ -864,9 +790,9 @@ class _VoiceChipsState extends ConsumerState<_VoiceChips> {
790 ),
791 const SizedBox(height: 12),
792 ],
867 - if (males.isNotEmpty) ...[
793 + if (females.isNotEmpty) ...[
794 Text(
869 - 'Male',
795 + 'Female',
796 style: theme.textTheme.labelSmall?.copyWith(
797 color: theme.colorScheme.onSurface.withValues(alpha: 0.5),
798 ),
@@ -875,7 +801,7 @@ class _VoiceChipsState extends ConsumerState<_VoiceChips> {
801 SingleChildScrollView(
802 scrollDirection: Axis.horizontal,
803 child: Row(
878 - children: males
804 + children: females
805 .map(
806 (v) => Padding(
807 padding: const EdgeInsets.only(right: 8),
pubspec.lock
+72 -40
@@ -329,22 +329,6 @@ packages:
329 url: "https://pub.dev"
330 source: hosted
331 version: "2.1.2"
332 - dort:
333 - dependency: transitive
334 - description:
335 - name: dort
336 - sha256: "6fd4d405ef4f6a53b27761a5e5236081a251c0a0340b6a4f15a5926bea3cf307"
337 - url: "https://pub.dev"
338 - source: hosted
339 - version: "0.1.1"
340 - espeak:
341 - dependency: "direct dev"
342 - description:
343 - name: espeak
344 - sha256: b57d9da4a53e32b8db9a143463075f4510b44b4b5ea34c9ebd1d17a7ea36308a
345 - url: "https://pub.dev"
346 - source: hosted
347 - version: "0.1.3"
332 extended_image:
333 dependency: transitive
334 description:
@@ -781,14 +765,6 @@ packages:
765 url: "https://pub.dev"
766 source: hosted
767 version: "0.13.0"
784 - meomeo:
785 - dependency: "direct main"
786 - description:
787 - name: meomeo
788 - sha256: "0b5bc652d52328348b794b9a2f6b4309a38e514b99bd39271f94430fa7dcb17b"
789 - url: "https://pub.dev"
790 - source: hosted
791 - version: "0.3.2"
768 meta:
769 dependency: transitive
770 description:
@@ -813,14 +789,6 @@ packages:
789 url: "https://pub.dev"
790 source: hosted
791 version: "0.17.6"
816 - native_toolchain_rust:
817 - dependency: transitive
818 - description:
819 - name: native_toolchain_rust
820 - sha256: "26d4dcae954328af4ebfa8fc56cca0bf74b9ae8b4a6132de5da2071ac3d237b3"
821 - url: "https://pub.dev"
822 - source: hosted
823 - version: "1.0.4"
792 node_preamble:
793 dependency: transitive
794 description:
@@ -1125,6 +1093,78 @@ packages:
1093 url: "https://pub.dev"
1094 source: hosted
1095 version: "3.0.0"
1096 + sherpa_onnx:
1097 + dependency: "direct main"
1098 + description:
1099 + name: sherpa_onnx
1100 + sha256: b5a503cedc0605134770015fd4dba00c62f109f8a16630a52ebc04ef99ccbf93
1101 + url: "https://pub.dev"
1102 + source: hosted
1103 + version: "1.13.2"
1104 + sherpa_onnx_android_arm64:
1105 + dependency: transitive
1106 + description:
1107 + name: sherpa_onnx_android_arm64
1108 + sha256: b6aeb7cd5b32c6d1bd209a4bff0f28044d95977620d20e1d926b0c31e0a0725f
1109 + url: "https://pub.dev"
1110 + source: hosted
1111 + version: "1.13.2"
1112 + sherpa_onnx_android_armeabi:
1113 + dependency: transitive
1114 + description:
1115 + name: sherpa_onnx_android_armeabi
1116 + sha256: ab647a0a4ca4b7c31159db1bdfc79b718882a088a2ed9757b340856d626715c0
1117 + url: "https://pub.dev"
1118 + source: hosted
1119 + version: "1.13.2"
1120 + sherpa_onnx_android_x86:
1121 + dependency: transitive
1122 + description:
1123 + name: sherpa_onnx_android_x86
1124 + sha256: df0e8bf68ff564bf8683f285ee3e65fb296ec118dbf6ce60a906bb1b3206120e
1125 + url: "https://pub.dev"
1126 + source: hosted
1127 + version: "1.13.2"
1128 + sherpa_onnx_android_x86_64:
1129 + dependency: transitive
1130 + description:
1131 + name: sherpa_onnx_android_x86_64
1132 + sha256: f9f63fafde9498bed9de45a0e1d3f2eb4d649ad257e5de30b91d139432750b84
1133 + url: "https://pub.dev"
1134 + source: hosted
1135 + version: "1.13.2"
1136 + sherpa_onnx_ios:
1137 + dependency: transitive
1138 + description:
1139 + name: sherpa_onnx_ios
1140 + sha256: "46b30fe5db3e0741026e94f09f3515dc60071eb970e12039265e1ba98c4d812d"
1141 + url: "https://pub.dev"
1142 + source: hosted
1143 + version: "1.13.2"
1144 + sherpa_onnx_linux:
1145 + dependency: transitive
1146 + description:
1147 + name: sherpa_onnx_linux
1148 + sha256: e0b2780d809a5be6cc36b7915bbe25dd15059e357a7f0ac684feb4e6ad4e20b7
1149 + url: "https://pub.dev"
1150 + source: hosted
1151 + version: "1.13.2"
1152 + sherpa_onnx_macos:
1153 + dependency: transitive
1154 + description:
1155 + name: sherpa_onnx_macos
1156 + sha256: "8049aae4e290d01ce51267541a770eb5a61c8164d46c1127406557995b054875"
1157 + url: "https://pub.dev"
1158 + source: hosted
1159 + version: "1.13.2"
1160 + sherpa_onnx_windows:
1161 + dependency: transitive
1162 + description:
1163 + name: sherpa_onnx_windows
1164 + sha256: "428c5fc2fea2d9083555d6281224ab7dba3a33a8fbc013de18ea843cf1236f14"
1165 + url: "https://pub.dev"
1166 + source: hosted
1167 + version: "1.13.2"
1168 sky_engine:
1169 dependency: transitive
1170 description: flutter
@@ -1283,14 +1323,6 @@ packages:
1323 url: "https://pub.dev"
1324 source: hosted
1325 version: "0.10.1"
1286 - toml:
1287 - dependency: transitive
1288 - description:
1289 - name: toml
1290 - sha256: "35a35f782228656a2af31e8c73d1353cc4ef3d683fd68af1111b44631879c05e"
1291 - url: "https://pub.dev"
1292 - source: hosted
1293 - version: "0.18.0"
1326 two_dimensional_scrollables:
1327 dependency: transitive
1328 description:
pubspec.yaml
+2 -4
@@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
16 # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
17 # In Windows, build-name is used as the major, minor, and patch parts
18 # of the product and file versions while build-number is used as the build suffix.
19 -version: 1.2.3+21
19 +version: 1.2.3+22
20
21 environment:
22 sdk: ^3.11.0
@@ -61,7 +61,7 @@ dependencies:
61 flutter_local_notifications: ^18.0.1
62 audioplayers: ^6.4.0
63 device_info_plus: ^12.4.0
64 - meomeo: ^0.3.2
64 + sherpa_onnx: ^1.13.2
65 archive: ^4.0.2
66
67 dev_dependencies:
@@ -75,7 +75,6 @@ dev_dependencies:
75 # rules and activating additional ones.
76 flutter_lints: ^6.0.0
77 build_runner: ^2.13.1
78 - espeak: ^0.1.3
78 change_app_package_name: ^1.4.0
79 objectbox_generator: any
80
@@ -93,7 +92,6 @@ flutter:
92 assets:
93 - assets/google_fonts/
94 - assets/images/
96 - - assets/espeak-data.zip
95 # An image asset can refer to one or more resolution-specific "variants", see
96 # https://flutter.dev/to/resolution-aware-images
97