@mentra/bluetooth-sdk 0.1.21-beta.0 → 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 +22 -11
- 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 +43 -3
- package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +7 -2
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +82 -6
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +5 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +32 -3
- package/android/src/main/java/com/mentra/bluetoothsdk/OtaManifest.kt +43 -3
- package/android/src/main/java/com/mentra/bluetoothsdk/camera/CameraModels.kt +17 -4
- 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/MentraLive.kt +161 -14
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +3 -0
- 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/streaming/StreamModels.kt +38 -1
- 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/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 +30 -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 +58 -25
- 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 +1 -0
- package/build/_internal.d.ts.map +1 -1
- package/build/_internal.js +1 -0
- package/build/_internal.js.map +1 -1
- package/build/_private/BluetoothSdkModule.d.ts +8 -2
- package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
- package/build/_private/BluetoothSdkModule.js +6 -3
- package/build/_private/BluetoothSdkModule.js.map +1 -1
- package/build/_private/cameraRequestPayload.d.ts +1 -1
- package/build/_private/cameraRequestPayload.d.ts.map +1 -1
- package/build/_private/cameraRequestPayload.js +6 -0
- package/build/_private/cameraRequestPayload.js.map +1 -1
- package/build/_private/photoRequestPayload.d.ts.map +1 -1
- package/build/_private/photoRequestPayload.js +10 -1
- 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 +7 -1
- package/build/index.js.map +1 -1
- package/ios/BluetoothSdkModule.swift +41 -2
- package/ios/Source/BluetoothSdkDefaults.swift +2 -1
- package/ios/Source/Bridge.swift +15 -3
- package/ios/Source/DeviceManager.swift +67 -17
- package/ios/Source/DeviceStore.swift +5 -1
- package/ios/Source/MentraBluetoothSDK.swift +37 -8
- package/ios/Source/OtaManifest.swift +47 -4
- package/ios/Source/camera/CameraModels.swift +20 -5
- 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/MentraLive.swift +174 -38
- package/ios/Source/sgcs/SGCManager.swift +8 -0
- package/ios/Source/sgcs/ar99/ota/Ar99OtaManager.swift +637 -0
- package/ios/Source/streaming/StreamModels.swift +62 -5
- 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 -1
- package/src/BluetoothSdk.types.ts +61 -28
- package/src/_internal.ts +1 -0
- package/src/_private/BluetoothSdkModule.ts +20 -6
- package/src/_private/cameraRequestPayload.ts +8 -2
- package/src/_private/photoRequestPayload.ts +12 -2
- package/src/_private/sdkOtaManifest.ts +31 -0
- package/src/index.ts +9 -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
|
+
}
|
|
@@ -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,6 +467,7 @@ 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
|
|
440
473
|
// (emitting side lands in PR #3488); absent on older firmware and on
|
|
@@ -443,7 +476,10 @@ data class StreamStatusEvent(
|
|
|
443
476
|
var willRetry: Boolean? = null
|
|
444
477
|
private set
|
|
445
478
|
|
|
446
|
-
constructor(values: Map<String, Any>) : this(
|
|
479
|
+
constructor(values: Map<String, Any>) : this(
|
|
480
|
+
status = StreamStatus.fromMap(values),
|
|
481
|
+
stats = StreamLiveStats.fromMap(stringMapValue(values["stats"])),
|
|
482
|
+
) {
|
|
447
483
|
willRetry = boolValue(values, "willRetry")
|
|
448
484
|
}
|
|
449
485
|
|
|
@@ -453,6 +489,7 @@ data class StreamStatusEvent(
|
|
|
453
489
|
val values: Map<String, Any>
|
|
454
490
|
get() = buildMap {
|
|
455
491
|
putAll(status.toEventMap())
|
|
492
|
+
stats?.let { put("stats", it.toMap()) }
|
|
456
493
|
willRetry?.let { put("willRetry", it) }
|
|
457
494
|
}
|
|
458
495
|
}
|
|
@@ -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) {
|
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
package com.mentra.bluetoothsdk.utils.audio;
|
|
2
|
+
|
|
3
|
+
import android.util.Log;
|
|
4
|
+
|
|
5
|
+
import java.util.Arrays;
|
|
6
|
+
import java.util.concurrent.ArrayBlockingQueue;
|
|
7
|
+
|
|
8
|
+
import io.github.jaredmdobson.OpusDecoder;
|
|
9
|
+
import io.github.jaredmdobson.OpusException;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* AR99 Opus decoder worker.
|
|
13
|
+
*
|
|
14
|
+
* <p>The caller feeds BLE notify bytes through {@link #submitBleNotify(byte[])}. This class owns
|
|
15
|
+
* queueing, frame reassembly, Opus decoder state, and decoded PCM callbacks.
|
|
16
|
+
*/
|
|
17
|
+
public final class Ar99OpusPcmDecoder extends Thread {
|
|
18
|
+
public interface Callback {
|
|
19
|
+
void onPcmDecoded(byte[] pcmData);
|
|
20
|
+
|
|
21
|
+
default void onNotifyProcessed(int rawLen, int pcmFrames, int pcmBytesOut) {}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
private enum TaskType {
|
|
25
|
+
DECODE,
|
|
26
|
+
RESET,
|
|
27
|
+
STOP
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
private static final class Task {
|
|
31
|
+
final TaskType type;
|
|
32
|
+
final byte[] payload;
|
|
33
|
+
|
|
34
|
+
Task(TaskType type, byte[] payload) {
|
|
35
|
+
this.type = type;
|
|
36
|
+
this.payload = payload;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
private static final int SAMPLE_RATE = 16000;
|
|
41
|
+
private static final int CHANNELS = 1;
|
|
42
|
+
private static final int MAX_FRAME_SAMPLES = 5760;
|
|
43
|
+
private static final int FRAME_BYTES = 48;
|
|
44
|
+
private static final int HEADER_BYTES = 8;
|
|
45
|
+
private static final int MAX_BUFFER_SIZE = 10000;
|
|
46
|
+
private static final int TASK_QUEUE_CAPACITY = 256;
|
|
47
|
+
private static final int MAX_DIAG_FRAMES = 8;
|
|
48
|
+
private static final byte[] EXPECTED_PREFIX = new byte[] {0x00, 0x00, 0x00, 0x28};
|
|
49
|
+
private static final String TAG = "Ar99OpusPcmDecoder";
|
|
50
|
+
|
|
51
|
+
private final Callback callback;
|
|
52
|
+
private final OpusDecoder decoder;
|
|
53
|
+
private final ArrayBlockingQueue<Task> taskQueue = new ArrayBlockingQueue<>(TASK_QUEUE_CAPACITY);
|
|
54
|
+
|
|
55
|
+
private volatile boolean running = true;
|
|
56
|
+
|
|
57
|
+
private byte[] buffer = new byte[0];
|
|
58
|
+
private long totalNotifyCount = 0L;
|
|
59
|
+
private long totalFrameCount = 0L;
|
|
60
|
+
private long totalDecodeFailCount = 0L;
|
|
61
|
+
private long totalHeaderMismatchCount = 0L;
|
|
62
|
+
private long totalShortChunkCount = 0L;
|
|
63
|
+
private long totalResyncDropBytes = 0L;
|
|
64
|
+
private boolean headerLooksStable = true;
|
|
65
|
+
|
|
66
|
+
public Ar99OpusPcmDecoder(Callback callback) throws OpusException {
|
|
67
|
+
super("ar99-opus-decode");
|
|
68
|
+
this.callback = callback;
|
|
69
|
+
this.decoder = new OpusDecoder(SAMPLE_RATE, CHANNELS);
|
|
70
|
+
start();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
public void submitBleNotify(byte[] opusPayload) {
|
|
74
|
+
if (!running || opusPayload == null || opusPayload.length == 0) {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
byte[] payloadCopy = Arrays.copyOf(opusPayload, opusPayload.length);
|
|
78
|
+
if (!taskQueue.offer(new Task(TaskType.DECODE, payloadCopy))) {
|
|
79
|
+
Log.w(TAG, "AR99 Opus task queue full, drop notify len=" + payloadCopy.length);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
public void resetSession() {
|
|
84
|
+
if (!running) {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
taskQueue.clear();
|
|
88
|
+
taskQueue.offer(new Task(TaskType.RESET, null));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
public void resetBuffer() {
|
|
92
|
+
resetSession();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
public void shutdownDecoder() {
|
|
96
|
+
if (!running) {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
running = false;
|
|
100
|
+
taskQueue.clear();
|
|
101
|
+
taskQueue.offer(new Task(TaskType.STOP, null));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
public synchronized byte[] decodePacketToPcm16Mono(byte[] opusPayload) {
|
|
105
|
+
return decodePayload(opusPayload);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
@Override
|
|
109
|
+
public void run() {
|
|
110
|
+
try {
|
|
111
|
+
while (true) {
|
|
112
|
+
Task task = taskQueue.take();
|
|
113
|
+
if (task.type == TaskType.STOP) {
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
116
|
+
if (task.type == TaskType.RESET) {
|
|
117
|
+
resetDecoderState();
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (task.payload != null && task.payload.length > 0) {
|
|
121
|
+
handleChunk(task.payload);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
} catch (InterruptedException e) {
|
|
125
|
+
if (running) {
|
|
126
|
+
Log.w(TAG, "AR99 Opus worker interrupted: " + e.getMessage());
|
|
127
|
+
}
|
|
128
|
+
} finally {
|
|
129
|
+
taskQueue.clear();
|
|
130
|
+
resetDecoderState();
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
private synchronized void resetDecoderState() {
|
|
135
|
+
decoder.resetState();
|
|
136
|
+
buffer = new byte[0];
|
|
137
|
+
totalNotifyCount = 0L;
|
|
138
|
+
totalFrameCount = 0L;
|
|
139
|
+
totalDecodeFailCount = 0L;
|
|
140
|
+
totalHeaderMismatchCount = 0L;
|
|
141
|
+
totalShortChunkCount = 0L;
|
|
142
|
+
totalResyncDropBytes = 0L;
|
|
143
|
+
headerLooksStable = true;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
private synchronized void handleChunk(byte[] chunk) {
|
|
147
|
+
totalNotifyCount++;
|
|
148
|
+
if (chunk.length < FRAME_BYTES) {
|
|
149
|
+
totalShortChunkCount++;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (buffer.length + chunk.length > MAX_BUFFER_SIZE) {
|
|
153
|
+
Log.w(
|
|
154
|
+
TAG,
|
|
155
|
+
"AR99 Opus buffer overflow, clear"
|
|
156
|
+
+ ", oldBuf="
|
|
157
|
+
+ buffer.length
|
|
158
|
+
+ ", chunk="
|
|
159
|
+
+ chunk.length
|
|
160
|
+
+ ", notify#="
|
|
161
|
+
+ totalNotifyCount);
|
|
162
|
+
buffer = new byte[0];
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
int oldBufferLen = buffer.length;
|
|
166
|
+
buffer = concat(buffer, chunk);
|
|
167
|
+
|
|
168
|
+
int pcmFrames = 0;
|
|
169
|
+
int pcmBytesOut = 0;
|
|
170
|
+
int decodeFail = 0;
|
|
171
|
+
int skipPayload = 0;
|
|
172
|
+
int headerMismatch = 0;
|
|
173
|
+
|
|
174
|
+
while (buffer.length >= EXPECTED_PREFIX.length) {
|
|
175
|
+
int frameStart = findFrameStart(buffer, 0);
|
|
176
|
+
if (frameStart < 0) {
|
|
177
|
+
int keep = Math.min(buffer.length, EXPECTED_PREFIX.length - 1);
|
|
178
|
+
int drop = buffer.length - keep;
|
|
179
|
+
if (drop > 0) {
|
|
180
|
+
totalResyncDropBytes += drop;
|
|
181
|
+
headerLooksStable = false;
|
|
182
|
+
Log.w(
|
|
183
|
+
TAG,
|
|
184
|
+
"AR99 Opus resync drop(no header)"
|
|
185
|
+
+ " notify#="
|
|
186
|
+
+ totalNotifyCount
|
|
187
|
+
+ " dropBytes="
|
|
188
|
+
+ drop
|
|
189
|
+
+ " keepTail="
|
|
190
|
+
+ keep
|
|
191
|
+
+ " tailHex="
|
|
192
|
+
+ hex(Arrays.copyOfRange(buffer, drop, buffer.length), keep));
|
|
193
|
+
buffer = Arrays.copyOfRange(buffer, drop, buffer.length);
|
|
194
|
+
}
|
|
195
|
+
break;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (frameStart > 0) {
|
|
199
|
+
totalResyncDropBytes += frameStart;
|
|
200
|
+
headerLooksStable = false;
|
|
201
|
+
headerMismatch++;
|
|
202
|
+
totalHeaderMismatchCount++;
|
|
203
|
+
Log.w(
|
|
204
|
+
TAG,
|
|
205
|
+
"AR99 Opus resync shift"
|
|
206
|
+
+ " notify#="
|
|
207
|
+
+ totalNotifyCount
|
|
208
|
+
+ " dropBytes="
|
|
209
|
+
+ frameStart
|
|
210
|
+
+ " bufLen="
|
|
211
|
+
+ buffer.length
|
|
212
|
+
+ " droppedHex="
|
|
213
|
+
+ hex(Arrays.copyOfRange(buffer, 0, frameStart), Math.min(frameStart, 24)));
|
|
214
|
+
buffer = Arrays.copyOfRange(buffer, frameStart, buffer.length);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (buffer.length < FRAME_BYTES) {
|
|
218
|
+
break;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
byte[] frame = Arrays.copyOfRange(buffer, 0, FRAME_BYTES);
|
|
222
|
+
buffer = Arrays.copyOfRange(buffer, FRAME_BYTES, buffer.length);
|
|
223
|
+
totalFrameCount++;
|
|
224
|
+
|
|
225
|
+
boolean prefixOk = true;
|
|
226
|
+
for (int i = 0; i < EXPECTED_PREFIX.length; i++) {
|
|
227
|
+
if (frame[i] != EXPECTED_PREFIX[i]) {
|
|
228
|
+
prefixOk = false;
|
|
229
|
+
break;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
if (!prefixOk) {
|
|
233
|
+
headerMismatch++;
|
|
234
|
+
totalHeaderMismatchCount++;
|
|
235
|
+
headerLooksStable = false;
|
|
236
|
+
if (totalFrameCount <= MAX_DIAG_FRAMES || !headerLooksStable) {
|
|
237
|
+
Log.w(
|
|
238
|
+
TAG,
|
|
239
|
+
"AR99 Opus frame header mismatch"
|
|
240
|
+
+ " frame#="
|
|
241
|
+
+ totalFrameCount
|
|
242
|
+
+ " notify#="
|
|
243
|
+
+ totalNotifyCount
|
|
244
|
+
+ " remain="
|
|
245
|
+
+ buffer.length
|
|
246
|
+
+ " frameHex="
|
|
247
|
+
+ hex(frame, FRAME_BYTES));
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
byte[] opusPayload = Arrays.copyOfRange(frame, HEADER_BYTES, FRAME_BYTES);
|
|
252
|
+
if (opusPayload.length == 0) {
|
|
253
|
+
skipPayload++;
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
byte[] pcm = decodePayload(opusPayload);
|
|
258
|
+
if (pcm != null && pcm.length > 0) {
|
|
259
|
+
pcmFrames++;
|
|
260
|
+
pcmBytesOut += pcm.length;
|
|
261
|
+
if (callback != null) {
|
|
262
|
+
callback.onPcmDecoded(pcm);
|
|
263
|
+
}
|
|
264
|
+
} else {
|
|
265
|
+
decodeFail++;
|
|
266
|
+
totalDecodeFailCount++;
|
|
267
|
+
if (totalFrameCount <= MAX_DIAG_FRAMES || !headerLooksStable) {
|
|
268
|
+
Log.w(
|
|
269
|
+
TAG,
|
|
270
|
+
"AR99 Opus frame decode failed"
|
|
271
|
+
+ " frame#="
|
|
272
|
+
+ totalFrameCount
|
|
273
|
+
+ " notify#="
|
|
274
|
+
+ totalNotifyCount
|
|
275
|
+
+ " prefixOk="
|
|
276
|
+
+ prefixOk
|
|
277
|
+
+ " frameHex="
|
|
278
|
+
+ hex(frame, FRAME_BYTES)
|
|
279
|
+
+ " payloadHex="
|
|
280
|
+
+ hex(opusPayload, opusPayload.length));
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (chunk.length != FRAME_BYTES
|
|
286
|
+
|| oldBufferLen != 0
|
|
287
|
+
|| decodeFail > 0
|
|
288
|
+
|| headerMismatch > 0
|
|
289
|
+
|| skipPayload > 0
|
|
290
|
+
|| pcmFrames == 0
|
|
291
|
+
|| !headerLooksStable) {
|
|
292
|
+
Log.d(
|
|
293
|
+
TAG,
|
|
294
|
+
"AR99 Opus decode summary"
|
|
295
|
+
+ " notify#="
|
|
296
|
+
+ totalNotifyCount
|
|
297
|
+
+ " inLen="
|
|
298
|
+
+ chunk.length
|
|
299
|
+
+ " pcmFrames="
|
|
300
|
+
+ pcmFrames
|
|
301
|
+
+ " decodeFail="
|
|
302
|
+
+ decodeFail
|
|
303
|
+
+ " headerMismatch="
|
|
304
|
+
+ headerMismatch
|
|
305
|
+
+ " skipPayload="
|
|
306
|
+
+ skipPayload
|
|
307
|
+
+ " bufRemain="
|
|
308
|
+
+ buffer.length
|
|
309
|
+
+ " totals={frames="
|
|
310
|
+
+ totalFrameCount
|
|
311
|
+
+ ",fail="
|
|
312
|
+
+ totalDecodeFailCount
|
|
313
|
+
+ ",headerMismatch="
|
|
314
|
+
+ totalHeaderMismatchCount
|
|
315
|
+
+ ",shortChunk="
|
|
316
|
+
+ totalShortChunkCount
|
|
317
|
+
+ ",resyncDropBytes="
|
|
318
|
+
+ totalResyncDropBytes
|
|
319
|
+
+ "}");
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (callback != null) {
|
|
323
|
+
callback.onNotifyProcessed(chunk.length, pcmFrames, pcmBytesOut);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
private byte[] decodePayload(byte[] opusPayload) {
|
|
328
|
+
if (opusPayload == null || opusPayload.length == 0) {
|
|
329
|
+
return null;
|
|
330
|
+
}
|
|
331
|
+
byte[] pcm = new byte[MAX_FRAME_SAMPLES * CHANNELS * 2];
|
|
332
|
+
try {
|
|
333
|
+
int samples = decoder.decode(opusPayload, 0, opusPayload.length, pcm, 0, MAX_FRAME_SAMPLES, false);
|
|
334
|
+
if (samples <= 0) {
|
|
335
|
+
return null;
|
|
336
|
+
}
|
|
337
|
+
int bytes = samples * CHANNELS * 2;
|
|
338
|
+
return Arrays.copyOf(pcm, bytes);
|
|
339
|
+
} catch (OpusException | AssertionError | RuntimeException e) {
|
|
340
|
+
Log.w(
|
|
341
|
+
TAG,
|
|
342
|
+
"AR99 Opus decode failed len="
|
|
343
|
+
+ opusPayload.length
|
|
344
|
+
+ ", payloadHex="
|
|
345
|
+
+ hex(opusPayload, opusPayload.length)
|
|
346
|
+
+ ": "
|
|
347
|
+
+ e.getMessage());
|
|
348
|
+
return null;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
private static int findFrameStart(byte[] data, int fromIndex) {
|
|
353
|
+
if (data == null || data.length < EXPECTED_PREFIX.length) {
|
|
354
|
+
return -1;
|
|
355
|
+
}
|
|
356
|
+
int max = data.length - EXPECTED_PREFIX.length;
|
|
357
|
+
for (int i = Math.max(0, fromIndex); i <= max; i++) {
|
|
358
|
+
boolean match = true;
|
|
359
|
+
for (int j = 0; j < EXPECTED_PREFIX.length; j++) {
|
|
360
|
+
if (data[i + j] != EXPECTED_PREFIX[j]) {
|
|
361
|
+
match = false;
|
|
362
|
+
break;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
if (match) {
|
|
366
|
+
return i;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
return -1;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
private static String hex(byte[] data, int maxBytes) {
|
|
373
|
+
if (data == null || data.length == 0 || maxBytes <= 0) {
|
|
374
|
+
return "";
|
|
375
|
+
}
|
|
376
|
+
int len = Math.min(data.length, maxBytes);
|
|
377
|
+
StringBuilder sb = new StringBuilder(len * 3);
|
|
378
|
+
for (int i = 0; i < len; i++) {
|
|
379
|
+
if (i > 0) {
|
|
380
|
+
sb.append(' ');
|
|
381
|
+
}
|
|
382
|
+
sb.append(String.format("%02X", data[i] & 0xFF));
|
|
383
|
+
}
|
|
384
|
+
if (data.length > len) {
|
|
385
|
+
sb.append("...");
|
|
386
|
+
}
|
|
387
|
+
return sb.toString();
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
private static byte[] concat(byte[] a, byte[] b) {
|
|
391
|
+
if (a.length == 0) {
|
|
392
|
+
return Arrays.copyOf(b, b.length);
|
|
393
|
+
}
|
|
394
|
+
byte[] out = Arrays.copyOf(a, a.length + b.length);
|
|
395
|
+
System.arraycopy(b, 0, out, a.length, b.length);
|
|
396
|
+
return out;
|
|
397
|
+
}
|
|
398
|
+
}
|