@mentra/bluetooth-sdk 0.1.8 → 0.1.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. package/README.md +116 -18
  2. package/android/build.gradle +289 -16
  3. package/android/gradle.properties.example +9 -0
  4. package/android/lc3Lib/build.gradle +95 -6
  5. package/android/settings.gradle +3 -0
  6. package/android/silero/LICENSE.md +17 -0
  7. package/android/silero/build.gradle +42 -0
  8. package/android/silero/proguard-rules.pro +24 -0
  9. package/android/silero/src/androidTest/assets/hello.wav +0 -0
  10. package/android/silero/src/androidTest/java/com/konovalov/vad/silero/VadSileroTest.kt +79 -0
  11. package/android/silero/src/main/assets/LICENSE +21 -0
  12. package/android/silero/src/main/assets/silero_vad.onnx +0 -0
  13. package/android/silero/src/main/java/com/konovalov/vad/silero/Vad.kt +160 -0
  14. package/android/silero/src/main/java/com/konovalov/vad/silero/VadSilero.kt +449 -0
  15. package/android/silero/src/main/java/com/konovalov/vad/silero/config/FrameSize.kt +15 -0
  16. package/android/silero/src/main/java/com/konovalov/vad/silero/config/Mode.kt +14 -0
  17. package/android/silero/src/main/java/com/konovalov/vad/silero/config/SampleRate.kt +12 -0
  18. package/android/silero/src/main/java/com/konovalov/vad/silero/utils/AudioUtils.kt +47 -0
  19. package/android/silero/src/main/java/com/konovalov/vad/silero/utils/TensorMap.kt +41 -0
  20. package/android/silero/src/test/java/com/konovalov/vad/silero/utils/AudioUtilsTest.kt +44 -0
  21. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkDefaults.kt +6 -0
  22. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +165 -27
  23. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +89 -21
  24. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +189 -16
  25. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +22 -2
  26. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +787 -85
  27. package/android/src/main/java/com/mentra/bluetoothsdk/audio/AudioModels.kt +6 -2
  28. package/android/src/main/java/com/mentra/bluetoothsdk/camera/CameraModels.kt +123 -10
  29. package/android/src/main/java/com/mentra/bluetoothsdk/controllers/ControllerManager.kt +12 -2
  30. package/android/src/main/java/com/mentra/bluetoothsdk/controllers/R1.kt +8 -2
  31. package/android/src/main/java/com/mentra/bluetoothsdk/debug/BleTraceLogger.kt +33 -17
  32. package/android/src/main/java/com/mentra/bluetoothsdk/events/BluetoothEvents.kt +101 -0
  33. package/android/src/main/java/com/mentra/bluetoothsdk/internal/MapParsing.kt +8 -0
  34. package/android/src/main/java/com/mentra/bluetoothsdk/photoreceiver/LocalPhotoReceiverRegistry.java +108 -0
  35. package/android/src/main/java/com/mentra/bluetoothsdk/photoreceiver/LocalPhotoUploadServer.kt +399 -74
  36. package/android/src/main/java/com/mentra/bluetoothsdk/photoreceiver/MentraPhotoReceiverModule.kt +100 -17
  37. package/android/src/main/java/com/mentra/bluetoothsdk/requests/DisplayRequests.kt +17 -0
  38. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.java +2 -2
  39. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +569 -220
  40. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Mach1.java +2 -2
  41. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.java +271 -66
  42. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +176 -104
  43. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraosBle.java +12753 -27036
  44. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +54 -2
  45. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Simulated.kt +16 -4
  46. package/android/src/main/java/com/mentra/bluetoothsdk/status/DeviceStatus.kt +60 -3
  47. package/android/src/main/java/com/mentra/bluetoothsdk/streaming/StreamModels.kt +12 -3
  48. package/android/src/main/java/com/mentra/bluetoothsdk/stt/STTTools.kt +87 -36
  49. package/android/src/main/java/com/mentra/bluetoothsdk/stt/SherpaOnnxTranscriber.kt +13 -4
  50. package/android/src/main/java/com/mentra/bluetoothsdk/stt/VadGateSpeechPolicy.kt +229 -0
  51. package/android/src/main/java/com/mentra/bluetoothsdk/tts/TTSTools.kt +180 -0
  52. package/android/src/main/java/com/mentra/bluetoothsdk/utils/AvifExifStripper.java +573 -0
  53. package/android/src/main/java/com/mentra/bluetoothsdk/utils/BlePhotoUploadService.java +488 -50
  54. package/android/src/main/java/com/mentra/bluetoothsdk/utils/HeifExifTagReader.java +199 -0
  55. package/android/src/main/java/com/mentra/bluetoothsdk/utils/MessageChunkReassembler.java +18 -1
  56. package/android/src/main/java/com/mentra/bluetoothsdk/utils/MessageChunker.java +64 -18
  57. package/android/src/main/java/com/mentra/bluetoothsdk/utils/NexSGCUtils.kt +48 -39
  58. package/android/src/test/java/com/mentra/bluetoothsdk/camera/PhotoRequestTest.kt +23 -0
  59. package/android/src/test/java/com/mentra/bluetoothsdk/photoreceiver/LocalPhotoReceiverRegistryTest.java +75 -0
  60. package/android/src/test/java/com/mentra/bluetoothsdk/utils/AvifExifStripperTest.java +30 -0
  61. package/android/src/test/java/com/mentra/bluetoothsdk/utils/BlePhotoUploadServiceTest.java +98 -0
  62. package/android/src/test/resources/avif_with_exif.avif +0 -0
  63. package/build/BluetoothSdk.types.d.ts +241 -43
  64. package/build/BluetoothSdk.types.d.ts.map +1 -1
  65. package/build/BluetoothSdk.types.js +11 -10
  66. package/build/BluetoothSdk.types.js.map +1 -1
  67. package/build/_private/BluetoothSdkModule.d.ts +36 -23
  68. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  69. package/build/_private/BluetoothSdkModule.js +30 -52
  70. package/build/_private/BluetoothSdkModule.js.map +1 -1
  71. package/build/_private/photoRequestPayload.d.ts +4 -0
  72. package/build/_private/photoRequestPayload.d.ts.map +1 -0
  73. package/build/_private/photoRequestPayload.js +25 -0
  74. package/build/_private/photoRequestPayload.js.map +1 -0
  75. package/build/index.d.ts +1 -1
  76. package/build/index.d.ts.map +1 -1
  77. package/build/index.js +23 -0
  78. package/build/index.js.map +1 -1
  79. package/build/react/useMentraBluetooth.d.ts +2 -2
  80. package/build/react/useMentraBluetooth.d.ts.map +1 -1
  81. package/build/react/useMentraBluetooth.js +3 -3
  82. package/build/react/useMentraBluetooth.js.map +1 -1
  83. package/ios/BluetoothSdkModule.swift +174 -70
  84. package/ios/LocalPhotoUploadServer.swift +370 -105
  85. package/ios/MentraBluetoothSDK.podspec +3 -1
  86. package/ios/MentraPhotoReceiverModule.swift +62 -10
  87. package/ios/Packages/CoreObjC/include/PcmConverter.h +22 -0
  88. package/ios/Packages/SherpaOnnx/SherpaOnnx.swift +668 -26
  89. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/Info.plist +13 -9
  90. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64/Headers/sherpa-onnx/c-api/c-api.h +4551 -0
  91. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64/Headers/sherpa-onnx/c-api/cxx-api.h +1745 -0
  92. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64/libsherpa-onnx.a +0 -0
  93. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64_x86_64-simulator/Headers/sherpa-onnx/c-api/c-api.h +4551 -0
  94. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64_x86_64-simulator/Headers/sherpa-onnx/c-api/cxx-api.h +1745 -0
  95. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64_x86_64-simulator/libsherpa-onnx.a +0 -0
  96. package/ios/Source/BluetoothSdkDefaults.swift +6 -0
  97. package/ios/Source/Bridge.swift +58 -19
  98. package/ios/Source/DeviceManager.swift +149 -17
  99. package/ios/Source/DeviceStore.swift +16 -6
  100. package/ios/Source/MentraBluetoothSDK.swift +803 -47
  101. package/ios/Source/ObservableStore.swift +8 -2
  102. package/ios/Source/{Audio → audio}/AudioModels.swift +9 -7
  103. package/ios/Source/{Camera → camera}/CameraModels.swift +225 -16
  104. package/ios/Source/controllers/ControllerManager.swift +4 -3
  105. package/ios/Source/controllers/R1.swift +3 -3
  106. package/ios/Source/{Events → events}/BluetoothEvents.swift +183 -1
  107. package/ios/Source/{Internal → internal}/ValueParsing.swift +10 -1
  108. package/ios/Source/{Requests → requests}/DisplayRequests.swift +28 -4
  109. package/ios/Source/services/PhoneMic.swift +1 -0
  110. package/ios/Source/sgcs/G1.swift +2 -2
  111. package/ios/Source/sgcs/G2.swift +645 -403
  112. package/ios/Source/sgcs/Mach1.swift +2 -2
  113. package/ios/Source/sgcs/MentraLive.swift +812 -319
  114. package/ios/Source/sgcs/MentraNex.swift +412 -182
  115. package/ios/Source/sgcs/SGCManager.swift +57 -3
  116. package/ios/Source/sgcs/Simulated.swift +7 -3
  117. package/ios/Source/sgcs/mentraos_ble.pb.swift +3073 -3134
  118. package/ios/Source/status/DeviceStatus.swift +974 -0
  119. package/ios/Source/{Status → status}/WifiHotspotStatus.swift +4 -4
  120. package/ios/Source/{Streaming → streaming}/StreamModels.swift +24 -9
  121. package/ios/Source/stt/STTTools.swift +27 -12
  122. package/ios/Source/stt/SherpaOnnxTranscriber.swift +31 -8
  123. package/ios/Source/tts/TTSTools.swift +164 -0
  124. package/ios/Source/utils/JSCExperiment.swift +7 -7
  125. package/ios/Source/utils/MessageChunkReassembler.swift +20 -1
  126. package/ios/Source/utils/MessageChunker.swift +78 -21
  127. package/ios/Source/utils/TarBz2Extractor.swift +53 -2
  128. package/package.json +1 -1
  129. package/plugin/build/withAndroid.js +68 -4
  130. package/src/BluetoothSdk.types.ts +331 -59
  131. package/src/_private/BluetoothSdkModule.ts +113 -102
  132. package/src/_private/photoRequestPayload.ts +28 -0
  133. package/src/index.ts +58 -15
  134. package/src/react/useMentraBluetooth.ts +6 -4
  135. package/android/.project +0 -28
  136. package/android/lc3Lib/.project +0 -28
  137. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/Headers/cargs.h +0 -162
  138. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/Headers/sherpa-onnx/c-api/c-api.h +0 -1852
  139. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/Headers/sherpa-onnx/c-api/cxx-api.h +0 -674
  140. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64/sherpa-onnx.a +0 -0
  141. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64_x86_64-simulator/sherpa-onnx.a +0 -0
  142. package/ios/Source/Status/DeviceStatus.swift +0 -471
  143. /package/ios/Source/{Connection → connection}/ScanSession.swift +0 -0
  144. /package/ios/Source/{Errors → errors}/BluetoothError.swift +0 -0
  145. /package/ios/Source/{Internal → internal}/BluetoothAvailability.swift +0 -0
  146. /package/ios/Source/{Status → status}/RuntimeState.swift +0 -0
  147. /package/ios/Source/{Types → types}/DeviceModels.swift +0 -0
@@ -0,0 +1,229 @@
1
+ package com.mentra.bluetoothsdk.stt
2
+
3
+ import android.content.Context
4
+ import android.util.Log
5
+ import com.konovalov.vad.silero.Vad
6
+ import com.konovalov.vad.silero.VadSilero
7
+ import com.konovalov.vad.silero.config.FrameSize
8
+ import com.konovalov.vad.silero.config.Mode
9
+ import com.konovalov.vad.silero.config.SampleRate
10
+ import java.nio.ByteBuffer
11
+ import java.nio.ByteOrder
12
+
13
+ /**
14
+ * VadGateSpeechPolicy handles Voice Activity Detection using Silero VAD.
15
+ *
16
+ * Features:
17
+ * - Dynamic VAD check throttling (adjusts frequency based on silence duration)
18
+ * - 8-second silence timeout before declaring "not speaking"
19
+ * - Bypass modes for debugging and PCM streaming
20
+ * - 512-sample frame size (matches Sherpa-ONNX requirements)
21
+ *
22
+ * Ported from android_core VadGateSpeechPolicy.java
23
+ */
24
+ class VadGateSpeechPolicy(private val context: Context) {
25
+ companion object {
26
+ private const val TAG = "VadGateSpeechPolicy"
27
+
28
+ // Total required silence duration
29
+ private const val REQUIRED_SILENCE_DURATION_MS = 8000L
30
+
31
+ // Dynamic VAD check intervals
32
+ private const val INITIAL_SILENCE_VAD_INTERVAL_MS = 50L // Check frequently at first
33
+ private const val MEDIUM_SILENCE_VAD_INTERVAL_MS = 100L // Medium frequency after some time
34
+ private const val LONG_SILENCE_VAD_INTERVAL_MS =
35
+ 200L // Less frequent after extended silence
36
+
37
+ // Thresholds for switching intervals
38
+ private const val MEDIUM_SILENCE_THRESHOLD_MS =
39
+ 20000L // Switch to medium interval after 20s
40
+ private const val LONG_SILENCE_THRESHOLD_MS = 60000L // Switch to long interval after 60s
41
+
42
+ private const val FRAME_SIZE = 512
43
+ }
44
+
45
+ private var vad: VadSilero? = null
46
+ private var isCurrentlySpeech = false
47
+ private var bypassVadForDebugging = false
48
+ private var bypassVadForPCM = false
49
+
50
+ /** Notified whenever the speech/silence state actually flips. */
51
+ var onSpeechStateChanged: ((Boolean) -> Unit)? = null
52
+
53
+ // Timestamp of the last detected speech
54
+ private var lastSpeechDetectedTime = 0L
55
+ // Throttle timer for silence VAD checks
56
+ private var lastVadCheckTime = 0L
57
+
58
+ /** Initialize the Silero VAD model */
59
+ fun init(blockSizeSamples: Int) {
60
+ startVad()
61
+ }
62
+
63
+ private fun startVad() {
64
+ try {
65
+ // Set internal silence duration very low; external logic manages the full required
66
+ // silence duration
67
+ vad =
68
+ Vad.builder()
69
+ .setContext(context)
70
+ .setSampleRate(SampleRate.SAMPLE_RATE_16K)
71
+ .setFrameSize(FrameSize.FRAME_SIZE_512)
72
+ .setMode(Mode.NORMAL)
73
+ .setSilenceDurationMs(50)
74
+ .setSpeechDurationMs(50)
75
+ .build()
76
+
77
+ Log.d(TAG, "VAD initialized successfully")
78
+ } catch (e: Exception) {
79
+ Log.e(TAG, "Failed to initialize VAD", e)
80
+ vad = null
81
+ }
82
+ }
83
+
84
+ /** Check if audio should be passed to the recognizer based on VAD state */
85
+ fun shouldPassAudioToRecognizer(): Boolean {
86
+ // CRITICAL: Handle VAD null case
87
+ if (vad == null) {
88
+ return bypassVadForDebugging || bypassVadForPCM || true
89
+ }
90
+
91
+ return bypassVadForDebugging || bypassVadForPCM || isCurrentlySpeech
92
+ }
93
+
94
+ /** Process audio bytes through VAD to detect speech */
95
+ fun processAudioBytes(bytes: ByteArray, offset: Int, length: Int) {
96
+ val now = System.currentTimeMillis()
97
+
98
+ // If in speech state and it hasn't been 8 seconds since the last speech was detected, skip
99
+ // processing
100
+ if (isCurrentlySpeech && (now - lastSpeechDetectedTime < REQUIRED_SILENCE_DURATION_MS)) {
101
+ return
102
+ }
103
+
104
+ // Calculate silence duration
105
+ val silenceDuration = now - lastSpeechDetectedTime
106
+
107
+ // Determine which VAD interval to use based on silence duration
108
+ val currentVadInterval =
109
+ when {
110
+ silenceDuration < MEDIUM_SILENCE_THRESHOLD_MS -> INITIAL_SILENCE_VAD_INTERVAL_MS
111
+ silenceDuration < LONG_SILENCE_THRESHOLD_MS -> MEDIUM_SILENCE_VAD_INTERVAL_MS
112
+ else -> LONG_SILENCE_VAD_INTERVAL_MS
113
+ }
114
+
115
+ // During silence, throttle VAD checks based on the dynamic interval
116
+ if (!isCurrentlySpeech && (now - lastVadCheckTime < currentVadInterval)) {
117
+ return
118
+ }
119
+ lastVadCheckTime = now
120
+
121
+ val audioBytesFull = bytesToShort(bytes)
122
+ val totalSamples = audioBytesFull.size
123
+
124
+ if (totalSamples % FRAME_SIZE != 0) {
125
+ Log.e(
126
+ TAG,
127
+ "Invalid audio frame size: $totalSamples samples. Needs to be multiple of $FRAME_SIZE."
128
+ )
129
+ return
130
+ }
131
+
132
+ val currentVad =
133
+ vad
134
+ ?: run {
135
+ Log.w(TAG, "VAD not initialized, skipping audio processing")
136
+ return
137
+ }
138
+
139
+ var previousSpeechState = isCurrentlySpeech
140
+
141
+ // Process each 512-sample frame
142
+ val numFrames = totalSamples / FRAME_SIZE
143
+ for (i in 0 until numFrames) {
144
+ val currentTime = System.currentTimeMillis()
145
+ val startIdx = i * FRAME_SIZE
146
+ val audioFrame = audioBytesFull.copyOfRange(startIdx, startIdx + FRAME_SIZE)
147
+ val detectedSpeech = currentVad.isSpeech(audioFrame)
148
+
149
+ if (detectedSpeech) {
150
+ isCurrentlySpeech = true
151
+ // Update the last speech detection timestamp
152
+ lastSpeechDetectedTime = currentTime
153
+ } else {
154
+ // If no speech detected, and 8 seconds have elapsed since the last speech, mark as
155
+ // silence
156
+ if (currentTime - lastSpeechDetectedTime >= REQUIRED_SILENCE_DURATION_MS) {
157
+ isCurrentlySpeech = false
158
+ }
159
+ }
160
+
161
+ if (isCurrentlySpeech != previousSpeechState) {
162
+ Log.d(
163
+ TAG,
164
+ "Speech detection changed to: ${if (isCurrentlySpeech) "SPEECH" else "SILENCE"}"
165
+ )
166
+ previousSpeechState = isCurrentlySpeech
167
+ onSpeechStateChanged?.invoke(isCurrentlySpeech)
168
+ }
169
+ }
170
+ }
171
+
172
+ /** Convert byte array (PCM16LE) to short array */
173
+ private fun bytesToShort(bytes: ByteArray): ShortArray {
174
+ val shorts = ShortArray(bytes.size / 2)
175
+ ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN).asShortBuffer().get(shorts)
176
+ return shorts
177
+ }
178
+
179
+ /** Stop VAD and release resources */
180
+ fun stop() {
181
+ try {
182
+ vad?.close()
183
+ vad = null
184
+ Log.d(TAG, "VAD stopped and resources released")
185
+ } catch (e: Exception) {
186
+ Log.e(TAG, "Error stopping VAD", e)
187
+ }
188
+ }
189
+
190
+ /** Reset VAD state */
191
+ fun reset() {
192
+ val wasSpeaking = isCurrentlySpeech
193
+ isCurrentlySpeech = false
194
+ lastSpeechDetectedTime = 0
195
+ lastVadCheckTime = 0
196
+ if (wasSpeaking) onSpeechStateChanged?.invoke(false)
197
+ }
198
+
199
+ /** Change bypass VAD for debugging state */
200
+ fun changeBypassVadForDebugging(bypass: Boolean) {
201
+ bypassVadForDebugging = bypass
202
+ Log.d(TAG, "Bypass VAD for debugging: $bypass")
203
+ }
204
+
205
+ /** Change bypass VAD for PCM streaming state */
206
+ fun changeBypassVadForPCM(bypass: Boolean) {
207
+ Log.d(TAG, "VAD PCM Bypass State Change: $bypassVadForPCM -> $bypass")
208
+ bypassVadForPCM = bypass
209
+ }
210
+
211
+ /** Handle microphone state changes */
212
+ fun microphoneStateChanged(state: Boolean) {
213
+ if (!state) {
214
+ // Microphone turned off: force immediate silence
215
+ val wasSpeaking = isCurrentlySpeech
216
+ isCurrentlySpeech = false
217
+ lastSpeechDetectedTime = 0
218
+ lastVadCheckTime = 0
219
+
220
+ // Optionally flush the VAD's internal state by processing a silent frame
221
+ vad?.let { vadInstance ->
222
+ val silentFrame = ShortArray(FRAME_SIZE)
223
+ vadInstance.isSpeech(silentFrame)
224
+ }
225
+ Log.d(TAG, "Microphone turned off; forced state to SILENCE.")
226
+ if (wasSpeaking) onSpeechStateChanged?.invoke(false)
227
+ }
228
+ }
229
+ }
@@ -0,0 +1,180 @@
1
+ package com.mentra.core.tts
2
+
3
+ import android.content.Context
4
+ import android.content.SharedPreferences
5
+ import com.k2fsa.sherpa.onnx.GenerationConfig
6
+ import com.k2fsa.sherpa.onnx.OfflineTts
7
+ import com.k2fsa.sherpa.onnx.OfflineTtsConfig
8
+ import com.k2fsa.sherpa.onnx.OfflineTtsModelConfig
9
+ import com.k2fsa.sherpa.onnx.OfflineTtsSupertonicModelConfig
10
+ import com.mentra.bluetoothsdk.Bridge
11
+ import java.io.File
12
+
13
+ /**
14
+ * Utilities for offline Sherpa-ONNX Supertonic 3 TTS model management.
15
+ *
16
+ * Supertonic 3 is a single multilingual model bundle (31 languages) with 10
17
+ * preset voice styles packed into a single voice.bin. Speaker IDs are indexed
18
+ * alphabetically by JSON filename, so the order is F1, F2, F3, F4, F5, M1, M2,
19
+ * M3, M4, M5 (sids 0..9). We default to F1 (sid 0) for a female voice.
20
+ */
21
+ object TTSTools {
22
+ private const val PREFS_NAME = "MentraPrefs"
23
+ private const val KEY_TTS_MODEL_PATH = "TTSModelPath"
24
+ private const val KEY_TTS_MODEL_LANGUAGE = "TTSModelLanguageCode"
25
+
26
+ private const val FILE_DURATION_PREDICTOR = "duration_predictor.int8.onnx"
27
+ private const val FILE_TEXT_ENCODER = "text_encoder.int8.onnx"
28
+ private const val FILE_VECTOR_ESTIMATOR = "vector_estimator.int8.onnx"
29
+ private const val FILE_VOCODER = "vocoder.int8.onnx"
30
+ private const val FILE_TTS_JSON = "tts.json"
31
+ private const val FILE_UNICODE_INDEXER = "unicode_indexer.bin"
32
+ private const val FILE_VOICE_STYLE = "voice.bin"
33
+
34
+ private val REQUIRED_FILES = listOf(
35
+ FILE_DURATION_PREDICTOR,
36
+ FILE_TEXT_ENCODER,
37
+ FILE_VECTOR_ESTIMATOR,
38
+ FILE_VOCODER,
39
+ FILE_TTS_JSON,
40
+ FILE_UNICODE_INDEXER,
41
+ FILE_VOICE_STYLE,
42
+ )
43
+
44
+ fun setTtsModelDetails(context: Context, path: String, languageCode: String) {
45
+ val prefs = getPrefs(context)
46
+ prefs.edit().apply {
47
+ putString(KEY_TTS_MODEL_PATH, path)
48
+ putString(KEY_TTS_MODEL_LANGUAGE, languageCode)
49
+ apply()
50
+ }
51
+ Bridge.log("TTS model details saved: path=$path, language=$languageCode")
52
+ }
53
+
54
+ fun getTtsModelPath(context: Context): String {
55
+ val prefs = getPrefs(context)
56
+ return prefs.getString(KEY_TTS_MODEL_PATH, "") ?: ""
57
+ }
58
+
59
+ fun getTtsModelLanguage(context: Context): String {
60
+ val prefs = getPrefs(context)
61
+ return prefs.getString(KEY_TTS_MODEL_LANGUAGE, "en-US") ?: "en-US"
62
+ }
63
+
64
+ fun checkTTSModelAvailable(context: Context): Boolean {
65
+ val modelPath = getTtsModelPath(context)
66
+ if (modelPath.isEmpty()) {
67
+ return false
68
+ }
69
+ return validateTTSModel(modelPath)
70
+ }
71
+
72
+ fun validateTTSModel(path: String): Boolean {
73
+ val modelDir = File(path)
74
+ if (!modelDir.exists() || !modelDir.isDirectory) {
75
+ Bridge.log("TTS model path does not exist or is not a directory: $path")
76
+ return false
77
+ }
78
+
79
+ for (name in REQUIRED_FILES) {
80
+ val file = File(modelDir, name)
81
+ if (!file.exists() || !file.canRead() || file.length() == 0L) {
82
+ Bridge.log("TTS model missing required file '$name' at: $path")
83
+ return false
84
+ }
85
+ }
86
+
87
+ return true
88
+ }
89
+
90
+ private val SUPPORTED_LANGS = setOf(
91
+ "en", "ko", "ja", "ar", "bg", "cs", "da", "de", "el", "es", "et",
92
+ "fi", "fr", "hi", "hr", "hu", "id", "it", "lt", "lv", "nl", "pl",
93
+ "pt", "ro", "ru", "sk", "sl", "sv", "tr", "uk", "vi",
94
+ )
95
+
96
+ /** Convert a BCP-47 tag like "en-US" or "fr-FR" to a Supertonic 2-letter code. */
97
+ private fun languageTagToSupertonicLang(tag: String?): String {
98
+ if (tag.isNullOrBlank()) return "en"
99
+ val primary = tag.substringBefore('-').lowercase()
100
+ return if (primary in SUPPORTED_LANGS) primary else "en"
101
+ }
102
+
103
+ fun generateTtsAudio(
104
+ context: Context,
105
+ text: String,
106
+ modelPath: String,
107
+ outputPath: String,
108
+ speakerId: Int,
109
+ speed: Float,
110
+ ): Boolean {
111
+ val languageTag = getTtsModelLanguage(context)
112
+ if (text.isBlank()) {
113
+ Bridge.log("TTS_ERROR: text is empty")
114
+ return false
115
+ }
116
+ if (!validateTTSModel(modelPath)) {
117
+ Bridge.log("TTS_ERROR: model is invalid: $modelPath")
118
+ return false
119
+ }
120
+
121
+ val modelDir = File(modelPath)
122
+ val outputFile = File(outputPath)
123
+ outputFile.parentFile?.mkdirs()
124
+
125
+ var tts: OfflineTts? = null
126
+ return try {
127
+ val supertonic = OfflineTtsSupertonicModelConfig(
128
+ durationPredictor = File(modelDir, FILE_DURATION_PREDICTOR).absolutePath,
129
+ textEncoder = File(modelDir, FILE_TEXT_ENCODER).absolutePath,
130
+ vectorEstimator = File(modelDir, FILE_VECTOR_ESTIMATOR).absolutePath,
131
+ vocoder = File(modelDir, FILE_VOCODER).absolutePath,
132
+ ttsJson = File(modelDir, FILE_TTS_JSON).absolutePath,
133
+ unicodeIndexer = File(modelDir, FILE_UNICODE_INDEXER).absolutePath,
134
+ voiceStyle = File(modelDir, FILE_VOICE_STYLE).absolutePath,
135
+ )
136
+
137
+ val modelConfig = OfflineTtsModelConfig(
138
+ supertonic = supertonic,
139
+ numThreads = 2,
140
+ provider = "cpu",
141
+ )
142
+
143
+ val config = OfflineTtsConfig(
144
+ model = modelConfig,
145
+ maxNumSentences = 1,
146
+ silenceScale = 0.2f,
147
+ )
148
+
149
+ tts = OfflineTts(null, config)
150
+
151
+ // sid out of range falls back to F1 (sid 0); clamp 0..9.
152
+ val sid = speakerId.coerceIn(0, tts.numSpeakers() - 1)
153
+ val clampedSpeed = speed.coerceIn(0.5f, 2.0f)
154
+ val lang = languageTagToSupertonicLang(languageTag)
155
+ val genConfig = GenerationConfig(
156
+ sid = sid,
157
+ speed = clampedSpeed,
158
+ extra = mapOf("lang" to lang),
159
+ )
160
+ val audio = tts.generateWithConfig(text = text, config = genConfig)
161
+ val saved = audio.save(outputFile.absolutePath)
162
+ Bridge.log("TTS generated ${outputFile.absolutePath}: saved=$saved sid=$sid lang=$lang")
163
+ saved
164
+ } catch (e: Exception) {
165
+ Bridge.log("TTS_ERROR: ${e.javaClass.simpleName}: ${e.message}")
166
+ e.printStackTrace()
167
+ false
168
+ } finally {
169
+ try {
170
+ tts?.release()
171
+ } catch (e: Exception) {
172
+ Bridge.log("TTS release failed: ${e.message}")
173
+ }
174
+ }
175
+ }
176
+
177
+ private fun getPrefs(context: Context): SharedPreferences {
178
+ return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
179
+ }
180
+ }