@mentra/bluetooth-sdk 0.1.20 → 0.1.21-beta.1
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.
- package/README.md +27 -12
- package/android/build.gradle +3 -0
- package/android/src/main/AndroidManifest.xml +3 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkAnalytics.kt +34 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkDefaults.kt +1 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +169 -26
- package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +72 -16
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +127 -8
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +4 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +400 -99
- package/android/src/main/java/com/mentra/bluetoothsdk/MentraLocalNetworkModule.kt +333 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/OtaManifest.kt +43 -3
- package/android/src/main/java/com/mentra/bluetoothsdk/camera/CameraModels.kt +18 -2
- package/android/src/main/java/com/mentra/bluetoothsdk/debug/BleTraceLogger.kt +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/services/Foreground.kt +53 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Ar99.java +2653 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.kt +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +16 -9
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Mach1.kt +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +342 -42
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Nimo.kt +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +7 -2
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Simulated.kt +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ar99/ota/Ar99OtaManager.java +566 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ar99/ota/OtaByteUtils.java +18 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ar99/ota/OtaCommandConstants.java +35 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ar99/ota/OtaCrc32Util.java +13 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ar99/ota/OtaGattTransport.java +11 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ar99/ota/OtaProtocol.java +92 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/status/WifiHotspotStatus.kt +19 -3
- package/android/src/main/java/com/mentra/bluetoothsdk/streaming/StreamModels.kt +52 -2
- package/android/src/main/java/com/mentra/bluetoothsdk/tts/TTSTools.kt +115 -47
- package/android/src/main/java/com/mentra/bluetoothsdk/types/DeviceModels.kt +7 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/BleJsonCompact.java +8 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/Constants.kt +2 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/PhoneAudioMonitor.kt +45 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/audio/Ar99OpusPcmDecoder.java +398 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/audio/PcmStreamPlayer.kt +328 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/OtaManifestDowngradeTest.kt +86 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/StreamStatusEventTest.kt +29 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/camera/PhotoRequestTest.kt +45 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/Ar99AdvertisementMatcherTest.java +22 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/utils/BleJsonCompactTest.java +20 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/utils/PhoneAudioMonitorTest.kt +45 -0
- package/build/BluetoothSdk.types.d.ts +100 -22
- package/build/BluetoothSdk.types.d.ts.map +1 -1
- package/build/BluetoothSdk.types.js +2 -1
- package/build/BluetoothSdk.types.js.map +1 -1
- package/build/_internal.d.ts +3 -0
- package/build/_internal.d.ts.map +1 -1
- package/build/_internal.js +3 -0
- package/build/_internal.js.map +1 -1
- package/build/_private/BluetoothSdkModule.d.ts +31 -4
- package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
- package/build/_private/BluetoothSdkModule.js +26 -3
- package/build/_private/BluetoothSdkModule.js.map +1 -1
- package/build/_private/MentraLocalNetworkModule.d.ts +37 -0
- package/build/_private/MentraLocalNetworkModule.d.ts.map +1 -0
- package/build/_private/MentraLocalNetworkModule.js +3 -0
- package/build/_private/MentraLocalNetworkModule.js.map +1 -0
- package/build/_private/cameraRequestPayload.d.ts +1 -1
- package/build/_private/cameraRequestPayload.d.ts.map +1 -1
- package/build/_private/cameraRequestPayload.js +7 -0
- package/build/_private/cameraRequestPayload.js.map +1 -1
- package/build/_private/photoRequestPayload.d.ts.map +1 -1
- package/build/_private/photoRequestPayload.js +10 -0
- package/build/_private/photoRequestPayload.js.map +1 -1
- package/build/_private/sdkOtaManifest.d.ts +20 -0
- package/build/_private/sdkOtaManifest.d.ts.map +1 -0
- package/build/_private/sdkOtaManifest.js +28 -0
- package/build/_private/sdkOtaManifest.js.map +1 -0
- package/build/index.d.ts +1 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js +11 -1
- package/build/index.js.map +1 -1
- package/expo-module.config.json +1 -0
- package/ios/BluetoothSdkModule.swift +113 -2
- package/ios/Source/BluetoothSdkDefaults.swift +2 -1
- package/ios/Source/Bridge.swift +87 -21
- package/ios/Source/DeviceManager.swift +118 -20
- package/ios/Source/DeviceStore.swift +4 -0
- package/ios/Source/MentraBluetoothSDK.swift +326 -33
- package/ios/Source/OtaManifest.swift +47 -4
- package/ios/Source/audio/PcmStreamPlayer.swift +410 -0
- package/ios/Source/camera/CameraModels.swift +26 -6
- package/ios/Source/internal/BleTraceLogger.swift +1 -1
- package/ios/Source/internal/BluetoothSdkAnalytics.swift +39 -0
- package/ios/Source/sgcs/Ar99.swift +2451 -0
- package/ios/Source/sgcs/G1.swift +1 -1
- package/ios/Source/sgcs/G2.swift +19 -7
- package/ios/Source/sgcs/Mach1.swift +1 -1
- package/ios/Source/sgcs/MentraLive.swift +478 -61
- package/ios/Source/sgcs/MentraNex.swift +1 -1
- package/ios/Source/sgcs/Nimo.swift +1 -1
- package/ios/Source/sgcs/SGCManager.swift +13 -2
- package/ios/Source/sgcs/Simulated.swift +1 -1
- package/ios/Source/sgcs/ar99/ota/Ar99OtaManager.swift +637 -0
- package/ios/Source/status/WifiHotspotStatus.swift +21 -2
- package/ios/Source/streaming/StreamModels.swift +68 -2
- package/ios/Source/tts/TTSTools.swift +1 -1
- package/ios/Source/types/DeviceModels.swift +13 -0
- package/ios/Source/utils/BleJsonCompact.swift +8 -0
- package/ios/Source/utils/Constants.swift +2 -0
- package/ios/Source/utils/PhoneAudioMonitor.swift +4 -0
- package/package.json +1 -2
- package/src/BluetoothSdk.types.ts +106 -24
- package/src/_internal.ts +3 -0
- package/src/_private/BluetoothSdkModule.ts +83 -18
- package/src/_private/MentraLocalNetworkModule.ts +50 -0
- package/src/_private/cameraRequestPayload.ts +9 -2
- package/src/_private/photoRequestPayload.ts +13 -4
- package/src/_private/sdkOtaManifest.ts +31 -0
- package/src/index.ts +13 -1
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
package com.mentra.bluetoothsdk.sgcs.ar99.ota;
|
|
2
|
+
|
|
3
|
+
import java.io.ByteArrayOutputStream;
|
|
4
|
+
|
|
5
|
+
public final class OtaProtocol {
|
|
6
|
+
private OtaProtocol() {}
|
|
7
|
+
|
|
8
|
+
public static byte[] buildFrame(byte cmd, byte paramType, byte[] payload) {
|
|
9
|
+
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
|
10
|
+
out.write(OtaCommandConstants.OTA.SVC_ID & 0xFF);
|
|
11
|
+
out.write(cmd & 0xFF);
|
|
12
|
+
out.write(paramType & 0xFF);
|
|
13
|
+
int len = payload != null ? payload.length : 0;
|
|
14
|
+
out.write(len & 0xFF);
|
|
15
|
+
out.write((len >> 8) & 0xFF);
|
|
16
|
+
if (payload != null && payload.length > 0) {
|
|
17
|
+
try {
|
|
18
|
+
out.write(payload);
|
|
19
|
+
} catch (Exception ignored) {
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return out.toByteArray();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
public static byte[] buildRequestUpgrade(int packageSize, int blockSize, long crc32) {
|
|
26
|
+
// Wire layout matches the vendor bring-up frame: type marker, then TLV
|
|
27
|
+
// 0x0A/len4 = package size (LE). CRC is validated per-block in SEND_IMAGE_DATA;
|
|
28
|
+
// blockSize is negotiated separately in CONNECT_NEGOTIATION.
|
|
29
|
+
ByteArrayOutputStream payload = new ByteArrayOutputStream();
|
|
30
|
+
payload.write(0x09);
|
|
31
|
+
payload.write(0x01);
|
|
32
|
+
payload.write(0x00);
|
|
33
|
+
payload.write(0x01);
|
|
34
|
+
payload.write(0x0A);
|
|
35
|
+
payload.write(0x04);
|
|
36
|
+
payload.write(packageSize & 0xFF);
|
|
37
|
+
payload.write((packageSize >> 8) & 0xFF);
|
|
38
|
+
payload.write((packageSize >> 16) & 0xFF);
|
|
39
|
+
payload.write((packageSize >> 24) & 0xFF);
|
|
40
|
+
payload.write(0x00);
|
|
41
|
+
return buildFrame(OtaCommandConstants.OTA.REQUEST_UPGRADE, (byte) 0x80, payload.toByteArray());
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
public static byte[] buildConnectNegotiation(int blockSize) {
|
|
45
|
+
return buildFrame(OtaCommandConstants.OTA.CONNECT_NEGOTIATION, (byte) 0x80, null);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
public static byte[] buildNegotiationResult() {
|
|
49
|
+
ByteArrayOutputStream payload = new ByteArrayOutputStream();
|
|
50
|
+
payload.write(0x01);
|
|
51
|
+
payload.write(0x01);
|
|
52
|
+
payload.write(0x00);
|
|
53
|
+
payload.write(0x01);
|
|
54
|
+
return buildFrame(OtaCommandConstants.OTA.NEGOTIATION_RESULT, (byte) 0x80, payload.toByteArray());
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
public static byte[] buildSendImageData(int psn, byte[] data) {
|
|
58
|
+
ByteArrayOutputStream payload = new ByteArrayOutputStream();
|
|
59
|
+
payload.write(psn & 0xFF);
|
|
60
|
+
long crc32 = OtaCrc32Util.calculate(data);
|
|
61
|
+
payload.write((byte) (crc32 & 0xFF));
|
|
62
|
+
payload.write((byte) ((crc32 >> 8) & 0xFF));
|
|
63
|
+
payload.write((byte) ((crc32 >> 16) & 0xFF));
|
|
64
|
+
payload.write((byte) ((crc32 >> 24) & 0xFF));
|
|
65
|
+
if (data != null && data.length > 0) {
|
|
66
|
+
try {
|
|
67
|
+
payload.write(data);
|
|
68
|
+
} catch (Exception ignored) {
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return buildFrame(OtaCommandConstants.OTA.SEND_IMAGE_DATA, (byte) 0x80, payload.toByteArray());
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
public static FrameHeader parseFrameHeader(byte[] frame) {
|
|
75
|
+
if (frame == null || frame.length < 5) return null;
|
|
76
|
+
FrameHeader header = new FrameHeader();
|
|
77
|
+
header.svcId = frame[0];
|
|
78
|
+
header.cmd = frame[1];
|
|
79
|
+
header.paramType = frame[2];
|
|
80
|
+
header.paramLen = OtaByteUtils.readShortLE(frame, 3) & 0xFFFF;
|
|
81
|
+
header.payloadOffset = 5;
|
|
82
|
+
return header;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
public static class FrameHeader {
|
|
86
|
+
public byte svcId;
|
|
87
|
+
public byte cmd;
|
|
88
|
+
public byte paramType;
|
|
89
|
+
public int paramLen;
|
|
90
|
+
public int payloadOffset;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -66,15 +66,31 @@ sealed interface WifiStatus {
|
|
|
66
66
|
}
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
data class WifiStatusEvent(
|
|
69
|
+
data class WifiStatusEvent @JvmOverloads constructor(
|
|
70
70
|
val status: WifiStatus,
|
|
71
|
+
/**
|
|
72
|
+
* Glasses-reported provisioning failure reason when THIS event is the verdict of a
|
|
73
|
+
* failed connect attempt; null for routine link-state updates. An attempt property,
|
|
74
|
+
* not a link property — which is why it lives on the event and not on [WifiStatus]:
|
|
75
|
+
* "connect_timeout" arrives with a disconnected status (never associated), while
|
|
76
|
+
* "connected_to_other_network" arrives with a *connected* status — the attempt
|
|
77
|
+
* failed and Android's auto-join left the glasses on (or returned them to) a
|
|
78
|
+
* different SSID than requested, so the link is genuinely up while the request
|
|
79
|
+
* genuinely failed. Sent by ASG client builds that include the WiFi error
|
|
80
|
+
* surfacing (v40+); older glasses never set it.
|
|
81
|
+
*/
|
|
82
|
+
val error: String? = null,
|
|
71
83
|
) {
|
|
72
|
-
internal constructor(values: Map<String, Any>) : this(
|
|
84
|
+
internal constructor(values: Map<String, Any>) : this(
|
|
85
|
+
WifiStatus.fromMap(values) ?: WifiStatus.Disconnected,
|
|
86
|
+
stringValue(values, "error")?.takeIf { it.isNotEmpty() },
|
|
87
|
+
)
|
|
73
88
|
internal constructor(connected: Boolean, ssid: String?, localIp: String?) : this(
|
|
74
89
|
WifiStatus.fromStoreFields(connected, ssid, localIp) ?: WifiStatus.Disconnected
|
|
75
90
|
)
|
|
76
91
|
|
|
77
|
-
val values: Map<String, Any>
|
|
92
|
+
val values: Map<String, Any>
|
|
93
|
+
get() = status.toEventMap() + (error?.let { mapOf("error" to it) } ?: emptyMap())
|
|
78
94
|
}
|
|
79
95
|
|
|
80
96
|
sealed interface HotspotStatus {
|
|
@@ -168,6 +168,38 @@ data class StreamResolvedConfig @JvmOverloads constructor(
|
|
|
168
168
|
}
|
|
169
169
|
}
|
|
170
170
|
|
|
171
|
+
/** Live encoder and device telemetry reported by the glasses while streaming. */
|
|
172
|
+
data class StreamLiveStats @JvmOverloads constructor(
|
|
173
|
+
val bitrate: Long? = null,
|
|
174
|
+
val fps: Double? = null,
|
|
175
|
+
val droppedFrames: Long? = null,
|
|
176
|
+
val duration: Long? = null,
|
|
177
|
+
val temperatureC: Double? = null,
|
|
178
|
+
) {
|
|
179
|
+
fun toMap(): Map<String, Any> =
|
|
180
|
+
buildMap {
|
|
181
|
+
bitrate?.let { put("bitrate", it) }
|
|
182
|
+
fps?.let { put("fps", it) }
|
|
183
|
+
droppedFrames?.let { put("droppedFrames", it) }
|
|
184
|
+
duration?.let { put("duration", it) }
|
|
185
|
+
temperatureC?.let { put("temperatureC", it) }
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
companion object {
|
|
189
|
+
@JvmStatic
|
|
190
|
+
fun fromMap(values: Map<String, Any>?): StreamLiveStats? {
|
|
191
|
+
values ?: return null
|
|
192
|
+
return StreamLiveStats(
|
|
193
|
+
bitrate = longValue(values, "bitrate"),
|
|
194
|
+
fps = doubleValue(values, "fps"),
|
|
195
|
+
droppedFrames = longValue(values, "droppedFrames"),
|
|
196
|
+
duration = longValue(values, "duration"),
|
|
197
|
+
temperatureC = doubleValue(values, "temperatureC"),
|
|
198
|
+
)
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
171
203
|
data class StreamRequest @JvmOverloads constructor(
|
|
172
204
|
val streamUrl: String,
|
|
173
205
|
val streamId: String = "",
|
|
@@ -435,13 +467,31 @@ sealed interface StreamStatus {
|
|
|
435
467
|
|
|
436
468
|
data class StreamStatusEvent(
|
|
437
469
|
val status: StreamStatus,
|
|
470
|
+
val stats: StreamLiveStats? = null,
|
|
438
471
|
) {
|
|
439
|
-
|
|
472
|
+
// True when the glasses will retry the failed publisher themselves
|
|
473
|
+
// (emitting side lands in PR #3488); absent on older firmware and on
|
|
474
|
+
// events not parsed from a glasses status map. Carried here instead of
|
|
475
|
+
// on StreamStatus.Error so the public Error shape stays unchanged.
|
|
476
|
+
var willRetry: Boolean? = null
|
|
477
|
+
private set
|
|
478
|
+
|
|
479
|
+
constructor(values: Map<String, Any>) : this(
|
|
480
|
+
status = StreamStatus.fromMap(values),
|
|
481
|
+
stats = StreamLiveStats.fromMap(stringMapValue(values["stats"])),
|
|
482
|
+
) {
|
|
483
|
+
willRetry = boolValue(values, "willRetry")
|
|
484
|
+
}
|
|
440
485
|
|
|
441
486
|
val state: StreamState get() = status.state
|
|
442
487
|
val streamId: String? get() = status.streamId
|
|
443
488
|
val resolvedConfig: StreamResolvedConfig? get() = status.resolvedConfig
|
|
444
|
-
val values: Map<String, Any>
|
|
489
|
+
val values: Map<String, Any>
|
|
490
|
+
get() = buildMap {
|
|
491
|
+
putAll(status.toEventMap())
|
|
492
|
+
stats?.let { put("stats", it.toMap()) }
|
|
493
|
+
willRetry?.let { put("willRetry", it) }
|
|
494
|
+
}
|
|
445
495
|
}
|
|
446
496
|
|
|
447
497
|
data class KeepAliveAckEvent(
|
|
@@ -9,6 +9,9 @@ import com.k2fsa.sherpa.onnx.OfflineTtsModelConfig
|
|
|
9
9
|
import com.k2fsa.sherpa.onnx.OfflineTtsSupertonicModelConfig
|
|
10
10
|
import com.mentra.bluetoothsdk.Bridge
|
|
11
11
|
import java.io.File
|
|
12
|
+
import java.util.concurrent.Executors
|
|
13
|
+
import java.util.concurrent.ScheduledFuture
|
|
14
|
+
import java.util.concurrent.TimeUnit
|
|
12
15
|
|
|
13
16
|
/**
|
|
14
17
|
* Utilities for offline Sherpa-ONNX Supertonic 3 TTS model management.
|
|
@@ -31,6 +34,21 @@ object TTSTools {
|
|
|
31
34
|
private const val FILE_UNICODE_INDEXER = "unicode_indexer.bin"
|
|
32
35
|
private const val FILE_VOICE_STYLE = "voice.bin"
|
|
33
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Keep the native model warm briefly after use to reduce repeated TTS latency while
|
|
39
|
+
* still reclaiming its memory when speech has been idle for a while.
|
|
40
|
+
*/
|
|
41
|
+
private const val MODEL_IDLE_TIMEOUT_MINUTES = 3L
|
|
42
|
+
private val modelLock = Any()
|
|
43
|
+
private val teardownExecutor =
|
|
44
|
+
Executors.newSingleThreadScheduledExecutor { runnable ->
|
|
45
|
+
Thread(runnable, "MentraTtsIdleTeardown").apply { isDaemon = true }
|
|
46
|
+
}
|
|
47
|
+
private var cachedTts: OfflineTts? = null
|
|
48
|
+
private var cachedModelPath: String? = null
|
|
49
|
+
private var teardownFuture: ScheduledFuture<*>? = null
|
|
50
|
+
private var teardownGeneration = 0L
|
|
51
|
+
|
|
34
52
|
private val REQUIRED_FILES = listOf(
|
|
35
53
|
FILE_DURATION_PREDICTOR,
|
|
36
54
|
FILE_TEXT_ENCODER,
|
|
@@ -42,6 +60,11 @@ object TTSTools {
|
|
|
42
60
|
)
|
|
43
61
|
|
|
44
62
|
fun setTtsModelDetails(context: Context, path: String, languageCode: String) {
|
|
63
|
+
synchronized(modelLock) {
|
|
64
|
+
if (cachedModelPath != null && cachedModelPath != path) {
|
|
65
|
+
releaseCachedModelLocked("model path changed")
|
|
66
|
+
}
|
|
67
|
+
}
|
|
45
68
|
val prefs = getPrefs(context)
|
|
46
69
|
prefs.edit().apply {
|
|
47
70
|
putString(KEY_TTS_MODEL_PATH, path)
|
|
@@ -118,59 +141,104 @@ object TTSTools {
|
|
|
118
141
|
return false
|
|
119
142
|
}
|
|
120
143
|
|
|
121
|
-
val modelDir = File(modelPath)
|
|
122
144
|
val outputFile = File(outputPath)
|
|
123
145
|
outputFile.parentFile?.mkdirs()
|
|
124
146
|
|
|
125
|
-
|
|
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 {
|
|
147
|
+
return synchronized(modelLock) {
|
|
169
148
|
try {
|
|
170
|
-
tts
|
|
149
|
+
val tts = getOrCreateModelLocked(modelPath)
|
|
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
|
+
numSteps = 5,
|
|
159
|
+
extra = mapOf("lang" to lang),
|
|
160
|
+
)
|
|
161
|
+
val audio = tts.generateWithConfig(text = text, config = genConfig)
|
|
162
|
+
val saved = audio.save(outputFile.absolutePath)
|
|
163
|
+
Bridge.log("TTS generated ${outputFile.absolutePath}: saved=$saved sid=$sid lang=$lang")
|
|
164
|
+
saved
|
|
171
165
|
} catch (e: Exception) {
|
|
172
|
-
Bridge.log("
|
|
166
|
+
Bridge.log("TTS_ERROR: ${e.javaClass.simpleName}: ${e.message}")
|
|
167
|
+
e.printStackTrace()
|
|
168
|
+
false
|
|
169
|
+
} finally {
|
|
170
|
+
scheduleIdleTeardownLocked()
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
private fun getOrCreateModelLocked(modelPath: String): OfflineTts {
|
|
176
|
+
cachedTts?.let { cached ->
|
|
177
|
+
if (cachedModelPath == modelPath) {
|
|
178
|
+
Bridge.log("TTS reusing cached model")
|
|
179
|
+
return cached
|
|
173
180
|
}
|
|
181
|
+
releaseCachedModelLocked("model path changed")
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
val modelDir = File(modelPath)
|
|
185
|
+
val supertonic = OfflineTtsSupertonicModelConfig(
|
|
186
|
+
durationPredictor = File(modelDir, FILE_DURATION_PREDICTOR).absolutePath,
|
|
187
|
+
textEncoder = File(modelDir, FILE_TEXT_ENCODER).absolutePath,
|
|
188
|
+
vectorEstimator = File(modelDir, FILE_VECTOR_ESTIMATOR).absolutePath,
|
|
189
|
+
vocoder = File(modelDir, FILE_VOCODER).absolutePath,
|
|
190
|
+
ttsJson = File(modelDir, FILE_TTS_JSON).absolutePath,
|
|
191
|
+
unicodeIndexer = File(modelDir, FILE_UNICODE_INDEXER).absolutePath,
|
|
192
|
+
voiceStyle = File(modelDir, FILE_VOICE_STYLE).absolutePath,
|
|
193
|
+
)
|
|
194
|
+
val modelConfig = OfflineTtsModelConfig(
|
|
195
|
+
supertonic = supertonic,
|
|
196
|
+
numThreads = 8,
|
|
197
|
+
provider = "cpu",
|
|
198
|
+
)
|
|
199
|
+
val config = OfflineTtsConfig(
|
|
200
|
+
model = modelConfig,
|
|
201
|
+
maxNumSentences = 1,
|
|
202
|
+
silenceScale = 0.2f,
|
|
203
|
+
)
|
|
204
|
+
return OfflineTts(null, config).also {
|
|
205
|
+
cachedTts = it
|
|
206
|
+
cachedModelPath = modelPath
|
|
207
|
+
Bridge.log("TTS loaded model; keeping it warm for $MODEL_IDLE_TIMEOUT_MINUTES minutes after use")
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
private fun scheduleIdleTeardownLocked() {
|
|
212
|
+
if (cachedTts == null) return
|
|
213
|
+
teardownFuture?.cancel(false)
|
|
214
|
+
val generation = ++teardownGeneration
|
|
215
|
+
teardownFuture =
|
|
216
|
+
teardownExecutor.schedule(
|
|
217
|
+
{
|
|
218
|
+
synchronized(modelLock) {
|
|
219
|
+
if (generation == teardownGeneration) {
|
|
220
|
+
releaseCachedModelLocked("idle for $MODEL_IDLE_TIMEOUT_MINUTES minutes")
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
},
|
|
224
|
+
MODEL_IDLE_TIMEOUT_MINUTES,
|
|
225
|
+
TimeUnit.MINUTES,
|
|
226
|
+
)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
private fun releaseCachedModelLocked(reason: String) {
|
|
230
|
+
teardownFuture?.cancel(false)
|
|
231
|
+
teardownFuture = null
|
|
232
|
+
teardownGeneration++
|
|
233
|
+
val tts = cachedTts
|
|
234
|
+
cachedTts = null
|
|
235
|
+
cachedModelPath = null
|
|
236
|
+
if (tts == null) return
|
|
237
|
+
try {
|
|
238
|
+
tts.release()
|
|
239
|
+
Bridge.log("TTS released cached model: $reason")
|
|
240
|
+
} catch (e: Exception) {
|
|
241
|
+
Bridge.log("TTS release failed: ${e.message}")
|
|
174
242
|
}
|
|
175
243
|
}
|
|
176
244
|
|
|
@@ -23,6 +23,7 @@ enum class DeviceModel(val deviceType: String) {
|
|
|
23
23
|
Z100(DeviceTypes.Z100),
|
|
24
24
|
FRAME(DeviceTypes.FRAME),
|
|
25
25
|
NIMO(DeviceTypes.NIMO),
|
|
26
|
+
AR99(DeviceTypes.AR99),
|
|
26
27
|
SIMULATED(DeviceTypes.SIMULATED),
|
|
27
28
|
R1(ControllerTypes.R1);
|
|
28
29
|
|
|
@@ -38,6 +39,7 @@ data class Device(
|
|
|
38
39
|
val name: String,
|
|
39
40
|
/** Android Bluetooth address when available. */
|
|
40
41
|
val address: String? = null,
|
|
42
|
+
val projectName: String? = null,
|
|
41
43
|
val rssi: Int? = null,
|
|
42
44
|
/** Stable app-facing scan-result key. Do not parse; use typed fields instead. */
|
|
43
45
|
val id: String = address?.takeIf { it.isNotBlank() } ?: "${model.deviceType}:$name",
|
|
@@ -50,6 +52,9 @@ data class Device(
|
|
|
50
52
|
address?.takeIf { it.isNotBlank() }?.let {
|
|
51
53
|
put("address", it)
|
|
52
54
|
}
|
|
55
|
+
projectName?.takeIf { it.isNotBlank() }?.let {
|
|
56
|
+
put("projectName", it)
|
|
57
|
+
}
|
|
53
58
|
rssi?.let { put("rssi", it) }
|
|
54
59
|
}
|
|
55
60
|
|
|
@@ -58,12 +63,14 @@ data class Device(
|
|
|
58
63
|
val model = stringValue(values, "model") ?: return null
|
|
59
64
|
val name = stringValue(values, "name") ?: return null
|
|
60
65
|
val address = stringValue(values, "address")?.takeIf { it.isNotBlank() }
|
|
66
|
+
val projectName = stringValue(values, "projectName")?.takeIf { it.isNotBlank() }
|
|
61
67
|
val rssi = numberValue(values, "rssi")
|
|
62
68
|
val id = stringValue(values, "id")?.takeIf { it.isNotBlank() } ?: address ?: "${model}:$name"
|
|
63
69
|
return Device(
|
|
64
70
|
model = DeviceModel.fromDeviceType(model),
|
|
65
71
|
name = name,
|
|
66
72
|
address = address,
|
|
73
|
+
projectName = projectName,
|
|
67
74
|
rssi = rssi,
|
|
68
75
|
id = id,
|
|
69
76
|
)
|
|
@@ -66,6 +66,14 @@ public final class BleJsonCompact {
|
|
|
66
66
|
putKey("transferMethod", "tm");
|
|
67
67
|
putKey("manual", "m");
|
|
68
68
|
putKey("reconnecting", "rc");
|
|
69
|
+
putKey("stats", "st");
|
|
70
|
+
putKey("width", "w");
|
|
71
|
+
putKey("height", "h");
|
|
72
|
+
putKey("bitrate", "br");
|
|
73
|
+
putKey("fps", "f");
|
|
74
|
+
putKey("droppedFrames", "df");
|
|
75
|
+
putKey("duration", "du");
|
|
76
|
+
putKey("temperatureC", "tc");
|
|
69
77
|
|
|
70
78
|
putEnum(PHOTO_STATUS_TO_INT, PHOTO_STATUS_FROM_INT, "accepted", 0);
|
|
71
79
|
putEnum(PHOTO_STATUS_TO_INT, PHOTO_STATUS_FROM_INT, "queued", 1);
|
|
@@ -10,7 +10,8 @@ object DeviceTypes {
|
|
|
10
10
|
const val FRAME = "Brilliant Frame"
|
|
11
11
|
const val G2 = "Even Realities G2"
|
|
12
12
|
const val NIMO = "NIMO"
|
|
13
|
-
val
|
|
13
|
+
const val AR99 = "AR99"
|
|
14
|
+
val ALL = arrayOf(SIMULATED, G1, G2, MACH1, LIVE, Z100, FRAME, NEX, NIMO, AR99)
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
object ControllerTypes {
|
|
@@ -26,6 +26,14 @@ class PhoneAudioMonitor private constructor(private val context: Context) {
|
|
|
26
26
|
@Volatile
|
|
27
27
|
private var instance: PhoneAudioMonitor? = null
|
|
28
28
|
|
|
29
|
+
internal fun shouldKeepOwnAppAudioSignal(
|
|
30
|
+
signalledPlaying: Boolean,
|
|
31
|
+
systemAudioPlaying: Boolean,
|
|
32
|
+
nowMs: Long,
|
|
33
|
+
holdoffUntilMs: Long,
|
|
34
|
+
): Boolean =
|
|
35
|
+
signalledPlaying && (systemAudioPlaying || nowMs < holdoffUntilMs)
|
|
36
|
+
|
|
29
37
|
@JvmStatic
|
|
30
38
|
fun getInstance(context: Context): PhoneAudioMonitor {
|
|
31
39
|
return instance ?: synchronized(this) {
|
|
@@ -48,6 +56,10 @@ class PhoneAudioMonitor private constructor(private val context: Context) {
|
|
|
48
56
|
private var listener: Listener? = null
|
|
49
57
|
private var isMonitoring = false
|
|
50
58
|
private var lastKnownState = false
|
|
59
|
+
// Set explicitly by AudioPlaybackService. Unlike isPlaying(), this is
|
|
60
|
+
// limited to Mentra's own playback and is safe for mic-watchdog gating.
|
|
61
|
+
@Volatile
|
|
62
|
+
private var ownAppAudioPlaying = false
|
|
51
63
|
|
|
52
64
|
// Rate limiting: max 1 state change notification per 500ms
|
|
53
65
|
// If state changes rapidly (true/false/true), we wait and send the final state
|
|
@@ -78,6 +90,37 @@ class PhoneAudioMonitor private constructor(private val context: Context) {
|
|
|
78
90
|
return audioManager.isMusicActive
|
|
79
91
|
}
|
|
80
92
|
|
|
93
|
+
/**
|
|
94
|
+
* Return the explicit React Native playback hint, reconciling it with
|
|
95
|
+
* Android's actual media state.
|
|
96
|
+
*
|
|
97
|
+
* Expo can miss a completion callback while its Activity is backgrounded,
|
|
98
|
+
* leaving the explicit hint stuck at PLAYING. The hint only protects the
|
|
99
|
+
* short A2DP codec transition; after that hold-off, Android's
|
|
100
|
+
* isMusicActive is authoritative. Clearing a stale hint lets the glasses
|
|
101
|
+
* mic watchdog recover instead of suppressing recovery forever.
|
|
102
|
+
*/
|
|
103
|
+
@Synchronized
|
|
104
|
+
fun isOwnAppAudioPlaying(): Boolean {
|
|
105
|
+
val systemAudioPlaying = isPlaying()
|
|
106
|
+
if (
|
|
107
|
+
shouldKeepOwnAppAudioSignal(
|
|
108
|
+
ownAppAudioPlaying,
|
|
109
|
+
systemAudioPlaying,
|
|
110
|
+
System.currentTimeMillis(),
|
|
111
|
+
audioStartHoldoffUntil,
|
|
112
|
+
)
|
|
113
|
+
) {
|
|
114
|
+
return true
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (ownAppAudioPlaying) {
|
|
118
|
+
ownAppAudioPlaying = false
|
|
119
|
+
Bridge.log("$TAG: Cleared stale own-app audio flag (system audio is inactive)")
|
|
120
|
+
}
|
|
121
|
+
return false
|
|
122
|
+
}
|
|
123
|
+
|
|
81
124
|
/**
|
|
82
125
|
* Notify the monitor that our own app started/stopped playing audio
|
|
83
126
|
* Called from RN AudioPlaybackService via Bridge
|
|
@@ -87,7 +130,9 @@ class PhoneAudioMonitor private constructor(private val context: Context) {
|
|
|
87
130
|
* there's a brief gap where the system reports 0 active configs (codec is switching).
|
|
88
131
|
* The hold-off prevents us from reporting STOPPED during this transition.
|
|
89
132
|
*/
|
|
133
|
+
@Synchronized
|
|
90
134
|
fun setOwnAppAudioPlaying(playing: Boolean) {
|
|
135
|
+
ownAppAudioPlaying = playing
|
|
91
136
|
Bridge.log("$TAG: Own app audio -> ${if (playing) "PLAYING" else "STOPPED"}")
|
|
92
137
|
|
|
93
138
|
if (playing) {
|