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