@mentra/bluetooth-sdk 3.2.1-dev.276 → 3.2.1-dev.277
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/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
- package/build/generated/releaseMetadata.js +5 -5
- package/build/generated/releaseMetadata.js.map +1 -1
- package/ios/Source/BluetoothSdkDefaults.swift +1 -1
- package/ios/Source/DeviceManager.swift +28 -50
- package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
- package/ios/Source/audio/PcmStreamPlayer.swift +12 -0
- package/ios/Source/services/PhoneMic.swift +1 -3
- package/ios/Source/utils/AudioRouteObservation.swift +54 -0
- package/ios/Source/utils/AudioSessionMonitor.swift +20 -6
- package/ios/Tests/AudioRouteObservationTests.swift +142 -0
- package/ios/Tests/BluetoothAudioRouteTests.swift +37 -0
- package/package.json +2 -2
- package/src/generated/releaseMetadata.ts +5 -5
|
@@ -3,9 +3,9 @@ package com.mentra.bluetoothsdk
|
|
|
3
3
|
/** Generated by release CI. Do not edit in a release checkout. */
|
|
4
4
|
internal object GeneratedReleaseMetadata {
|
|
5
5
|
const val FAMILY_BASE_VERSION: String = "3.2.1"
|
|
6
|
-
const val RELEASE_IDENTITY: String = "3.2.1-dev.
|
|
7
|
-
const val RELEASE_SET_ID: String = "mentra-3.2.1-dev.
|
|
8
|
-
const val SOURCE_COMMIT: String = "
|
|
9
|
-
const val OTA_MANIFEST_URL: String = "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.2.1/mentra-live-ota-3.2.1-dev.
|
|
10
|
-
const val OTA_MANIFEST_SHA256: String = "
|
|
6
|
+
const val RELEASE_IDENTITY: String = "3.2.1-dev.277"
|
|
7
|
+
const val RELEASE_SET_ID: String = "mentra-3.2.1-dev.277"
|
|
8
|
+
const val SOURCE_COMMIT: String = "2668275c49728812fea5356f6df3e78e73f1ed87"
|
|
9
|
+
const val OTA_MANIFEST_URL: String = "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.2.1/mentra-live-ota-3.2.1-dev.277.json"
|
|
10
|
+
const val OTA_MANIFEST_SHA256: String = "527d30a6400d5accdbf83e2c4316ec6e30e2b7ef1d70b6fd54e7abcec1bcc183"
|
|
11
11
|
}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
export const BLUETOOTH_SDK_RELEASE_METADATA = Object.freeze({
|
|
2
2
|
"schemaVersion": 1,
|
|
3
3
|
"familyBaseVersion": "3.2.1",
|
|
4
|
-
"releaseIdentity": "3.2.1-dev.
|
|
5
|
-
"releaseSetId": "mentra-3.2.1-dev.
|
|
6
|
-
"sourceCommit": "
|
|
7
|
-
"otaManifestUrl": "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.2.1/mentra-live-ota-3.2.1-dev.
|
|
8
|
-
"otaManifestSha256": "
|
|
4
|
+
"releaseIdentity": "3.2.1-dev.277",
|
|
5
|
+
"releaseSetId": "mentra-3.2.1-dev.277",
|
|
6
|
+
"sourceCommit": "2668275c49728812fea5356f6df3e78e73f1ed87",
|
|
7
|
+
"otaManifestUrl": "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.2.1/mentra-live-ota-3.2.1-dev.277.json",
|
|
8
|
+
"otaManifestSha256": "527d30a6400d5accdbf83e2c4316ec6e30e2b7ef1d70b6fd54e7abcec1bcc183"
|
|
9
9
|
});
|
|
10
10
|
//# sourceMappingURL=releaseMetadata.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"releaseMetadata.js","sourceRoot":"","sources":["../../src/generated/releaseMetadata.ts"],"names":[],"mappings":"AAWA,MAAM,CAAC,MAAM,8BAA8B,GAA0C,MAAM,CAAC,MAAM,CAAC;IACjG,eAAe,EAAE,CAAC;IAClB,mBAAmB,EAAE,OAAO;IAC5B,iBAAiB,EAAE,eAAe;IAClC,cAAc,EAAE,sBAAsB;IACtC,cAAc,EAAE,0CAA0C;IAC1D,gBAAgB,EAAE,iIAAiI;IACnJ,mBAAmB,EAAE,kEAAkE;CACxF,CAAC,CAAA","sourcesContent":["/** Generated by release CI. Do not edit in a release checkout. */\nexport interface BluetoothSdkReleaseMetadata {\n schemaVersion: 1\n familyBaseVersion: string | null\n releaseIdentity: string | null\n releaseSetId: string | null\n sourceCommit: string | null\n otaManifestUrl: string | null\n otaManifestSha256: string | null\n}\n\nexport const BLUETOOTH_SDK_RELEASE_METADATA: Readonly<BluetoothSdkReleaseMetadata> = Object.freeze({\n \"schemaVersion\": 1,\n \"familyBaseVersion\": \"3.2.1\",\n \"releaseIdentity\": \"3.2.1-dev.
|
|
1
|
+
{"version":3,"file":"releaseMetadata.js","sourceRoot":"","sources":["../../src/generated/releaseMetadata.ts"],"names":[],"mappings":"AAWA,MAAM,CAAC,MAAM,8BAA8B,GAA0C,MAAM,CAAC,MAAM,CAAC;IACjG,eAAe,EAAE,CAAC;IAClB,mBAAmB,EAAE,OAAO;IAC5B,iBAAiB,EAAE,eAAe;IAClC,cAAc,EAAE,sBAAsB;IACtC,cAAc,EAAE,0CAA0C;IAC1D,gBAAgB,EAAE,iIAAiI;IACnJ,mBAAmB,EAAE,kEAAkE;CACxF,CAAC,CAAA","sourcesContent":["/** Generated by release CI. Do not edit in a release checkout. */\nexport interface BluetoothSdkReleaseMetadata {\n schemaVersion: 1\n familyBaseVersion: string | null\n releaseIdentity: string | null\n releaseSetId: string | null\n sourceCommit: string | null\n otaManifestUrl: string | null\n otaManifestSha256: string | null\n}\n\nexport const BLUETOOTH_SDK_RELEASE_METADATA: Readonly<BluetoothSdkReleaseMetadata> = Object.freeze({\n \"schemaVersion\": 1,\n \"familyBaseVersion\": \"3.2.1\",\n \"releaseIdentity\": \"3.2.1-dev.277\",\n \"releaseSetId\": \"mentra-3.2.1-dev.277\",\n \"sourceCommit\": \"2668275c49728812fea5356f6df3e78e73f1ed87\",\n \"otaManifestUrl\": \"https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.2.1/mentra-live-ota-3.2.1-dev.277.json\",\n \"otaManifestSha256\": \"527d30a6400d5accdbf83e2c4316ec6e30e2b7ef1d70b6fd54e7abcec1bcc183\"\n})\n"]}
|
|
@@ -11,7 +11,7 @@ enum BluetoothSdkDefaults {
|
|
|
11
11
|
static let voiceActivityDetectionEnabled = false
|
|
12
12
|
static let loudnessGateEnabled = false
|
|
13
13
|
private static let infoSdkVersionKey = "MentraBluetoothSdkVersion"
|
|
14
|
-
private static let swiftPackageSdkVersion = "3.2.1-dev.
|
|
14
|
+
private static let swiftPackageSdkVersion = "3.2.1-dev.277"
|
|
15
15
|
private static let swiftPackageSdkVersionPlaceholder = "__MENTRA" + "_BLUETOOTH_SDK_VERSION__"
|
|
16
16
|
|
|
17
17
|
private static func normalizedSdkVersion(_ value: String?) -> String? {
|
|
@@ -50,6 +50,8 @@ struct ViewState {
|
|
|
50
50
|
private var cancellables = Set<AnyCancellable>()
|
|
51
51
|
#if os(macOS)
|
|
52
52
|
private var audioRouteObserver: MacAudioRouteObserver?
|
|
53
|
+
#else
|
|
54
|
+
private var audioRouteObservation: AudioRouteObservation?
|
|
53
55
|
#endif
|
|
54
56
|
var sendStateWorkItem: DispatchWorkItem?
|
|
55
57
|
let sendStateQueue = DispatchQueue(label: "sendStateQueue", qos: .userInitiated)
|
|
@@ -366,6 +368,18 @@ struct ViewState {
|
|
|
366
368
|
Bridge.log("MAN: init()")
|
|
367
369
|
super.init()
|
|
368
370
|
|
|
371
|
+
#if !os(macOS)
|
|
372
|
+
// Pair Audio precedes BLE readiness and microphone capture. Its audio
|
|
373
|
+
// state must not depend on the lazily created PhoneMic singleton.
|
|
374
|
+
audioRouteObservation = AudioRouteObservation(names: [
|
|
375
|
+
AVAudioSession.routeChangeNotification,
|
|
376
|
+
UIApplication.didBecomeActiveNotification,
|
|
377
|
+
]) { [weak self] in
|
|
378
|
+
self?.refreshAudioDeviceState()
|
|
379
|
+
self?.updateMicState()
|
|
380
|
+
}
|
|
381
|
+
#endif
|
|
382
|
+
|
|
369
383
|
// Start memory monitoring (logs every 30s to help detect leaks)
|
|
370
384
|
// MemoryMonitor.start()
|
|
371
385
|
|
|
@@ -929,6 +943,16 @@ struct ViewState {
|
|
|
929
943
|
}
|
|
930
944
|
|
|
931
945
|
func checkCurrentAudioDevice() {
|
|
946
|
+
#if os(macOS)
|
|
947
|
+
refreshAudioDeviceState()
|
|
948
|
+
#else
|
|
949
|
+
// DeviceStore calls this when the selected glasses change. Replace any
|
|
950
|
+
// pending route checks so the new target gets its own settling window.
|
|
951
|
+
audioRouteObservation?.refresh()
|
|
952
|
+
#endif
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
private func refreshAudioDeviceState() {
|
|
932
956
|
let audioDevicePattern = getAudioDevicePattern()
|
|
933
957
|
Bridge.log("MAN: checkCurrentAudioDevice: audioDevicePattern: \(audioDevicePattern)")
|
|
934
958
|
|
|
@@ -942,59 +966,13 @@ struct ViewState {
|
|
|
942
966
|
glassesBluetoothClassicConnected = AudioSessionMonitor.isAudioDeviceConnected(devicePattern: audioDevicePattern)
|
|
943
967
|
otherBtConnected = AudioSessionMonitor.isOtherAudioDeviceConnected(devicePattern: audioDevicePattern)
|
|
944
968
|
#else
|
|
945
|
-
//
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
)
|
|
949
|
-
|
|
950
|
-
if !isConnected {
|
|
951
|
-
Bridge.log("MAN: Device '\(deviceName)' disconnected")
|
|
952
|
-
glassesBluetoothClassicConnected = false
|
|
953
|
-
|
|
954
|
-
let isOtherDeviceConnected = AudioSessionMonitor.isOtherAudioDeviceConnected(
|
|
955
|
-
devicePattern: audioDevicePattern
|
|
956
|
-
)
|
|
957
|
-
if isOtherDeviceConnected {
|
|
958
|
-
Bridge.log("MAN: Other device connected, returning")
|
|
959
|
-
otherBtConnected = true
|
|
960
|
-
}
|
|
961
|
-
return
|
|
962
|
-
}
|
|
963
|
-
|
|
964
|
-
let isPaired = AudioSessionMonitor.isDevicePaired(devicePattern: audioDevicePattern)
|
|
965
|
-
if isPaired {
|
|
966
|
-
Bridge.log("MAN: Successfully detected newly paired device '\(audioDevicePattern)'")
|
|
967
|
-
glassesBluetoothClassicConnected = true
|
|
968
|
-
} else {
|
|
969
|
-
glassesBluetoothClassicConnected = false
|
|
970
|
-
}
|
|
969
|
+
// A connected HFP input remains eligible even while another output is
|
|
970
|
+
// selected. Inspect available inputs as well as the active A2DP route.
|
|
971
|
+
glassesBluetoothClassicConnected = AudioSessionMonitor.isDevicePaired(devicePattern: audioDevicePattern)
|
|
972
|
+
otherBtConnected = AudioSessionMonitor.isOtherAudioDeviceConnected(devicePattern: audioDevicePattern)
|
|
971
973
|
#endif
|
|
972
974
|
}
|
|
973
975
|
|
|
974
|
-
#if !os(macOS)
|
|
975
|
-
func onRouteChange(
|
|
976
|
-
reason: AVAudioSession.RouteChangeReason, availableInputs: [AVAudioSessionPortDescription]
|
|
977
|
-
) {
|
|
978
|
-
Bridge.log("MAN: onRouteChange: reason: \(reason)")
|
|
979
|
-
Bridge.log("MAN: onRouteChange: inputs: \(availableInputs)")
|
|
980
|
-
|
|
981
|
-
// check if our deviceName is connected:
|
|
982
|
-
// (return if deviceName is empty):
|
|
983
|
-
if deviceName.isEmpty {
|
|
984
|
-
Bridge.log("MAN: Device name is empty, returning")
|
|
985
|
-
return
|
|
986
|
-
}
|
|
987
|
-
|
|
988
|
-
// Add small delay to let iOS populate availableInputs
|
|
989
|
-
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in
|
|
990
|
-
guard let self = self else { return }
|
|
991
|
-
checkCurrentAudioDevice()
|
|
992
|
-
}
|
|
993
|
-
|
|
994
|
-
updateMicState()
|
|
995
|
-
}
|
|
996
|
-
#endif
|
|
997
|
-
|
|
998
976
|
func onInterruption(began: Bool) {
|
|
999
977
|
Bridge.log("MAN: Interruption: \(began)")
|
|
1000
978
|
systemMicUnavailable = began
|
|
@@ -3,9 +3,9 @@ import Foundation
|
|
|
3
3
|
/// Generated by release CI. Do not edit in a release checkout.
|
|
4
4
|
enum GeneratedReleaseMetadata {
|
|
5
5
|
static let familyBaseVersion = "3.2.1"
|
|
6
|
-
static let releaseIdentity = "3.2.1-dev.
|
|
7
|
-
static let releaseSetId = "mentra-3.2.1-dev.
|
|
8
|
-
static let sourceCommit = "
|
|
9
|
-
static let otaManifestUrl = "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.2.1/mentra-live-ota-3.2.1-dev.
|
|
10
|
-
static let otaManifestSha256 = "
|
|
6
|
+
static let releaseIdentity = "3.2.1-dev.277"
|
|
7
|
+
static let releaseSetId = "mentra-3.2.1-dev.277"
|
|
8
|
+
static let sourceCommit = "2668275c49728812fea5356f6df3e78e73f1ed87"
|
|
9
|
+
static let otaManifestUrl = "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.2.1/mentra-live-ota-3.2.1-dev.277.json"
|
|
10
|
+
static let otaManifestSha256 = "527d30a6400d5accdbf83e2c4316ec6e30e2b7ef1d70b6fd54e7abcec1bcc183"
|
|
11
11
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import AVFoundation
|
|
2
2
|
import Foundation
|
|
3
|
+
import os
|
|
3
4
|
|
|
4
5
|
private enum PcmStreamError: LocalizedError {
|
|
5
6
|
case invalidArgument(String)
|
|
@@ -25,6 +26,7 @@ private enum PcmStreamError: LocalizedError {
|
|
|
25
26
|
/// recovery, close, and abort deterministic without blocking Expo's module
|
|
26
27
|
/// queue.
|
|
27
28
|
final class PcmStreamPlayer: @unchecked Sendable {
|
|
29
|
+
private let logger = Logger(subsystem: "com.mentra.pcm-stream", category: "playback")
|
|
28
30
|
private static let backpressureCeilingMs: Int64 = 2000
|
|
29
31
|
private static let maximumBacklogMs: Int64 = 10000
|
|
30
32
|
|
|
@@ -37,6 +39,7 @@ final class PcmStreamPlayer: @unchecked Sendable {
|
|
|
37
39
|
|
|
38
40
|
private var queuedFrames: Int64 = 0
|
|
39
41
|
private var playedFrames: Int64 = 0
|
|
42
|
+
private var writtenFrames: Int64 = 0
|
|
40
43
|
private var closing = false
|
|
41
44
|
private var aborted = false
|
|
42
45
|
private var finished = false
|
|
@@ -107,6 +110,10 @@ final class PcmStreamPlayer: @unchecked Sendable {
|
|
|
107
110
|
}
|
|
108
111
|
|
|
109
112
|
let buffer = try makeBuffer(data: data, frameCount: frameCount)
|
|
113
|
+
if writtenFrames == 0 {
|
|
114
|
+
logger.info("PCM_PLAYBACK first_write frames=\(frameCount) sampleRate=\(self.sampleRate) engineRunning=\(self.engine.isRunning)")
|
|
115
|
+
}
|
|
116
|
+
writtenFrames += Int64(frameCount)
|
|
110
117
|
queuedFrames += Int64(frameCount)
|
|
111
118
|
player.scheduleBuffer(
|
|
112
119
|
buffer,
|
|
@@ -157,6 +164,7 @@ final class PcmStreamPlayer: @unchecked Sendable {
|
|
|
157
164
|
await withCheckedContinuation { continuation in
|
|
158
165
|
stateQueue.async { [self] in
|
|
159
166
|
if !aborted {
|
|
167
|
+
logger.info("PCM_PLAYBACK abort writtenFrames=\(self.writtenFrames) playedFrames=\(self.playedFrames) queuedFrames=\(self.queuedFrames)")
|
|
160
168
|
aborted = true
|
|
161
169
|
finished = true
|
|
162
170
|
player.stop()
|
|
@@ -201,6 +209,10 @@ final class PcmStreamPlayer: @unchecked Sendable {
|
|
|
201
209
|
|
|
202
210
|
private func didPlay(frameCount: Int) {
|
|
203
211
|
guard !aborted, terminalError == nil else { return }
|
|
212
|
+
if playedFrames == 0 {
|
|
213
|
+
let rate = sampleRate
|
|
214
|
+
logger.info("PCM_PLAYBACK first_played frames=\(frameCount) sampleRate=\(rate)")
|
|
215
|
+
}
|
|
204
216
|
queuedFrames = max(0, queuedFrames - Int64(frameCount))
|
|
205
217
|
playedFrames += Int64(frameCount)
|
|
206
218
|
|
|
@@ -359,9 +359,7 @@ class PhoneMic {
|
|
|
359
359
|
}
|
|
360
360
|
|
|
361
361
|
Bridge.log("MIC: handleRouteChange: \(reason)")
|
|
362
|
-
DeviceManager.
|
|
363
|
-
reason: reason, availableInputs: audioSession?.availableInputs ?? []
|
|
364
|
-
)
|
|
362
|
+
// DeviceManager observes readiness independently of microphone capture.
|
|
365
363
|
|
|
366
364
|
// // If we're recording and the audio route changed (e.g., AirPods connected/disconnected)
|
|
367
365
|
// if isRecording {
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import Combine
|
|
2
|
+
import Foundation
|
|
3
|
+
|
|
4
|
+
/// Keeps audio readiness observable before microphone capture has been started.
|
|
5
|
+
final class AudioRouteObservation {
|
|
6
|
+
private var subscriptions = Set<AnyCancellable>()
|
|
7
|
+
private var pendingRecheck: DispatchWorkItem?
|
|
8
|
+
private var generation = 0
|
|
9
|
+
private let settlingDelays: [TimeInterval]
|
|
10
|
+
private let onChange: () -> Void
|
|
11
|
+
|
|
12
|
+
init(
|
|
13
|
+
center: NotificationCenter = .default,
|
|
14
|
+
names: [Notification.Name],
|
|
15
|
+
settlingDelays: [TimeInterval] = [0.2, 0.3, 0.5],
|
|
16
|
+
onChange: @escaping () -> Void
|
|
17
|
+
) {
|
|
18
|
+
self.settlingDelays = settlingDelays
|
|
19
|
+
self.onChange = onChange
|
|
20
|
+
for name in names {
|
|
21
|
+
center.publisher(for: name)
|
|
22
|
+
.receive(on: DispatchQueue.main)
|
|
23
|
+
.sink { [weak self] _ in self?.refresh() }
|
|
24
|
+
.store(in: &subscriptions)
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
deinit {
|
|
29
|
+
pendingRecheck?.cancel()
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/// Start a new settling window for a route, foreground, or selected-device
|
|
33
|
+
/// change. The callback must read the current target and route each time.
|
|
34
|
+
func refresh() {
|
|
35
|
+
dispatchPrecondition(condition: .onQueue(.main))
|
|
36
|
+
pendingRecheck?.cancel()
|
|
37
|
+
generation += 1
|
|
38
|
+
reconcile(generation: generation, nextDelay: 0)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
private func reconcile(generation: Int, nextDelay: Int) {
|
|
42
|
+
guard generation == self.generation else { return }
|
|
43
|
+
onChange()
|
|
44
|
+
// availableInputs can lag the notification, including after removal.
|
|
45
|
+
// Reconcile at 200, 500, and 1000 ms even if the first read was ready.
|
|
46
|
+
// A reentrant refresh also supersedes this window.
|
|
47
|
+
guard generation == self.generation, nextDelay < settlingDelays.count else { return }
|
|
48
|
+
let recheck = DispatchWorkItem { [weak self] in
|
|
49
|
+
self?.reconcile(generation: generation, nextDelay: nextDelay + 1)
|
|
50
|
+
}
|
|
51
|
+
pendingRecheck = recheck
|
|
52
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + settlingDelays[nextDelay], execute: recheck)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -6,9 +6,10 @@
|
|
|
6
6
|
// Used to detect when Mentra Live glasses are paired/connected for audio
|
|
7
7
|
//
|
|
8
8
|
|
|
9
|
+
import Foundation
|
|
10
|
+
|
|
9
11
|
#if !os(macOS)
|
|
10
12
|
import AVFoundation
|
|
11
|
-
import Foundation
|
|
12
13
|
import UIKit
|
|
13
14
|
|
|
14
15
|
class AudioSessionMonitor {
|
|
@@ -57,11 +58,14 @@ class AudioSessionMonitor {
|
|
|
57
58
|
Bridge.log("AudioMonitor: Checking active route, output count: \(outputs.count)")
|
|
58
59
|
for output in outputs {
|
|
59
60
|
Bridge.log("AudioMonitor: - \(output.portName) (type: \(output.portType.rawValue))")
|
|
60
|
-
if
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
61
|
+
if BluetoothAudioRoute.matches(
|
|
62
|
+
name: output.portName,
|
|
63
|
+
portType: output.portType.rawValue,
|
|
64
|
+
target: devicePattern,
|
|
65
|
+
isIOSAppOnMac: ProcessInfo.processInfo.isiOSAppOnMac
|
|
66
|
+
) {
|
|
67
|
+
Bridge.log("AudioMonitor: ✅ Found active audio device: \(output.portName)")
|
|
68
|
+
return true
|
|
65
69
|
}
|
|
66
70
|
}
|
|
67
71
|
|
|
@@ -283,3 +287,13 @@ class AudioSessionMonitor {
|
|
|
283
287
|
}
|
|
284
288
|
}
|
|
285
289
|
#endif
|
|
290
|
+
|
|
291
|
+
/// AVAudioSession preserves the route name on iOS-on-Mac but reports the
|
|
292
|
+
/// CoreAudio transport "Bluetooth" instead of iPhone's profile-specific port.
|
|
293
|
+
enum BluetoothAudioRoute {
|
|
294
|
+
static func matches(name: String, portType: String, target: String, isIOSAppOnMac: Bool) -> Bool {
|
|
295
|
+
guard !target.isEmpty, name.localizedCaseInsensitiveContains(target) else { return false }
|
|
296
|
+
return portType == "BluetoothHFP" || portType == "BluetoothA2DPOutput"
|
|
297
|
+
|| (isIOSAppOnMac && portType == "Bluetooth")
|
|
298
|
+
}
|
|
299
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
@testable import MentraBluetoothSDK
|
|
3
|
+
import XCTest
|
|
4
|
+
|
|
5
|
+
final class AudioRouteObservationTests: XCTestCase {
|
|
6
|
+
@MainActor
|
|
7
|
+
func testInputsBecomingAvailableAfterNotificationAreReconciled() async {
|
|
8
|
+
let center = NotificationCenter()
|
|
9
|
+
let route = Notification.Name("test.audio.route")
|
|
10
|
+
var inputAvailable = false
|
|
11
|
+
var ready = false
|
|
12
|
+
let initialRead = expectation(description: "Initial unavailable input observed")
|
|
13
|
+
let changed = expectation(description: "Delayed input becomes ready without another event")
|
|
14
|
+
var firstRead = true
|
|
15
|
+
let observer = AudioRouteObservation(center: center, names: [route]) {
|
|
16
|
+
if firstRead {
|
|
17
|
+
firstRead = false
|
|
18
|
+
initialRead.fulfill()
|
|
19
|
+
}
|
|
20
|
+
if inputAvailable, !ready { changed.fulfill() }
|
|
21
|
+
ready = inputAvailable
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
center.post(name: route, object: nil)
|
|
25
|
+
await fulfillment(of: [initialRead], timeout: 2)
|
|
26
|
+
XCTAssertFalse(ready)
|
|
27
|
+
inputAvailable = true
|
|
28
|
+
await fulfillment(of: [changed], timeout: 1)
|
|
29
|
+
XCTAssertTrue(ready)
|
|
30
|
+
withExtendedLifetime(observer) {}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
@MainActor
|
|
34
|
+
func testInitiallyReadyInputCanDisappearDuringSettling() async {
|
|
35
|
+
var inputAvailable = true
|
|
36
|
+
var ready = false
|
|
37
|
+
let disappeared = expectation(description: "Input removal observed during settling")
|
|
38
|
+
let observer = AudioRouteObservation(names: [], settlingDelays: [0.05, 0.05]) {
|
|
39
|
+
if ready, !inputAvailable { disappeared.fulfill() }
|
|
40
|
+
ready = inputAvailable
|
|
41
|
+
}
|
|
42
|
+
observer.refresh()
|
|
43
|
+
XCTAssertTrue(ready)
|
|
44
|
+
inputAvailable = false
|
|
45
|
+
await fulfillment(of: [disappeared], timeout: 2)
|
|
46
|
+
XCTAssertFalse(ready)
|
|
47
|
+
withExtendedLifetime(observer) {}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
@MainActor
|
|
51
|
+
func testDeviceChangeReplacesPendingChecksAndReadsOnlyCurrentTarget() async {
|
|
52
|
+
var target = "first"
|
|
53
|
+
var availableInputs = Set<String>()
|
|
54
|
+
var observations: [(String, Bool)] = []
|
|
55
|
+
var secondChecks = 0
|
|
56
|
+
let settled = expectation(description: "Replacement window completed")
|
|
57
|
+
let observer = AudioRouteObservation(names: [], settlingDelays: [0.05, 0.05, 0.1]) {
|
|
58
|
+
observations.append((target, availableInputs.contains(target)))
|
|
59
|
+
if target == "second" {
|
|
60
|
+
secondChecks += 1
|
|
61
|
+
if secondChecks == 2 {
|
|
62
|
+
// The old target is available, but its readiness must not leak.
|
|
63
|
+
XCTAssertFalse(observations.contains { $0.1 })
|
|
64
|
+
availableInputs.insert("second")
|
|
65
|
+
}
|
|
66
|
+
if secondChecks == 4 { settled.fulfill() }
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
observer.refresh()
|
|
70
|
+
target = "second"
|
|
71
|
+
observer.refresh() // Same entry point used by DeviceStore's device_name change.
|
|
72
|
+
availableInputs.insert("first")
|
|
73
|
+
await fulfillment(of: [settled], timeout: 2)
|
|
74
|
+
XCTAssertEqual(observations.filter { $0.0 == "first" }.count, 1)
|
|
75
|
+
XCTAssertEqual(observations.filter { $0.0 == "second" }.count, 4)
|
|
76
|
+
XCTAssertEqual(observations.last?.0, "second")
|
|
77
|
+
XCTAssertEqual(observations.last?.1, true)
|
|
78
|
+
withExtendedLifetime(observer) {}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
@MainActor
|
|
82
|
+
func testUnavailableInputStopsRecheckingAfterBoundedWindow() async {
|
|
83
|
+
var checks = 0
|
|
84
|
+
let settled = expectation(description: "Immediate read and two scheduled rechecks completed")
|
|
85
|
+
let extraCheck = expectation(description: "No rechecks beyond the configured window")
|
|
86
|
+
extraCheck.isInverted = true
|
|
87
|
+
let observer = AudioRouteObservation(names: [], settlingDelays: [0.02, 0.02]) {
|
|
88
|
+
checks += 1
|
|
89
|
+
if checks == 3 { settled.fulfill() }
|
|
90
|
+
if checks > 3 { extraCheck.fulfill() }
|
|
91
|
+
}
|
|
92
|
+
observer.refresh()
|
|
93
|
+
await fulfillment(of: [settled], timeout: 2)
|
|
94
|
+
XCTAssertEqual(checks, 3)
|
|
95
|
+
await fulfillment(of: [extraCheck], timeout: 0.1)
|
|
96
|
+
XCTAssertEqual(checks, 3)
|
|
97
|
+
withExtendedLifetime(observer) {}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
@MainActor
|
|
101
|
+
func testReleasingObserverCancelsPendingRechecks() async throws {
|
|
102
|
+
var checks = 0
|
|
103
|
+
var observer: AudioRouteObservation? = AudioRouteObservation(names: [], settlingDelays: [0.02]) {
|
|
104
|
+
checks += 1
|
|
105
|
+
}
|
|
106
|
+
observer?.refresh()
|
|
107
|
+
XCTAssertEqual(checks, 1)
|
|
108
|
+
observer = nil
|
|
109
|
+
try await Task.sleep(nanoseconds: 100_000_000)
|
|
110
|
+
XCTAssertEqual(checks, 1)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
func testObservesRouteAndForegroundWithoutStartingAMicrophone() async {
|
|
114
|
+
let center = NotificationCenter()
|
|
115
|
+
let route = Notification.Name("test.audio.route")
|
|
116
|
+
let foreground = Notification.Name("test.audio.foreground")
|
|
117
|
+
let changed = expectation(description: "Both readiness events delivered")
|
|
118
|
+
changed.expectedFulfillmentCount = 2
|
|
119
|
+
let observer = AudioRouteObservation(center: center, names: [route, foreground]) {
|
|
120
|
+
XCTAssertTrue(Thread.isMainThread)
|
|
121
|
+
changed.fulfill()
|
|
122
|
+
}
|
|
123
|
+
center.post(name: route, object: nil)
|
|
124
|
+
center.post(name: foreground, object: nil)
|
|
125
|
+
await fulfillment(of: [changed], timeout: 1)
|
|
126
|
+
withExtendedLifetime(observer) {}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
func testReleasingObserverStopsReadinessCallbacks() async {
|
|
130
|
+
let center = NotificationCenter()
|
|
131
|
+
let route = Notification.Name("test.audio.route")
|
|
132
|
+
let changed = expectation(description: "Released observer is silent")
|
|
133
|
+
changed.isInverted = true
|
|
134
|
+
var observer: AudioRouteObservation? = AudioRouteObservation(center: center, names: [route]) {
|
|
135
|
+
changed.fulfill()
|
|
136
|
+
}
|
|
137
|
+
XCTAssertNotNil(observer)
|
|
138
|
+
observer = nil
|
|
139
|
+
center.post(name: route, object: nil)
|
|
140
|
+
await fulfillment(of: [changed], timeout: 0.1)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
@testable import MentraBluetoothSDK
|
|
2
|
+
import XCTest
|
|
3
|
+
|
|
4
|
+
final class BluetoothAudioRouteTests: XCTestCase {
|
|
5
|
+
func testRecognizesIPhoneProfiles() {
|
|
6
|
+
for port in ["BluetoothHFP", "BluetoothA2DPOutput"] {
|
|
7
|
+
XCTAssertTrue(BluetoothAudioRoute.matches(
|
|
8
|
+
name: "Mentra_Live_03BE", portType: port, target: "03BE", isIOSAppOnMac: false
|
|
9
|
+
))
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
func testRecognizesObservedMacTransportOnlyOnMac() {
|
|
14
|
+
XCTAssertTrue(BluetoothAudioRoute.matches(
|
|
15
|
+
name: "Mentra_Live_03BE", portType: "Bluetooth", target: "mentra_live_03be", isIOSAppOnMac: true
|
|
16
|
+
))
|
|
17
|
+
XCTAssertFalse(BluetoothAudioRoute.matches(
|
|
18
|
+
name: "Mentra_Live_03BE", portType: "Bluetooth", target: "03BE", isIOSAppOnMac: false
|
|
19
|
+
))
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
func testRejectsDifferentGlassesAndMissingIdentity() {
|
|
23
|
+
for target in ["023B", ""] {
|
|
24
|
+
XCTAssertFalse(BluetoothAudioRoute.matches(
|
|
25
|
+
name: "Mentra_Live_03BE", portType: "Bluetooth", target: target, isIOSAppOnMac: true
|
|
26
|
+
))
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
func testRejectsNonBluetoothDeviceWithMatchingName() {
|
|
31
|
+
for port in ["Speaker", "USBAudio", "BuiltInMic", ""] {
|
|
32
|
+
XCTAssertFalse(BluetoothAudioRoute.matches(
|
|
33
|
+
name: "Mentra_Live_03BE", portType: port, target: "03BE", isIOSAppOnMac: true
|
|
34
|
+
))
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mentra/bluetooth-sdk",
|
|
3
|
-
"version": "3.2.1-dev.
|
|
3
|
+
"version": "3.2.1-dev.277",
|
|
4
4
|
"description": "SDK for communicating with smart glasses",
|
|
5
5
|
"main": "build/index.js",
|
|
6
6
|
"react-native": "src/index.ts",
|
|
@@ -114,7 +114,7 @@
|
|
|
114
114
|
"registry": "https://registry.npmjs.org/"
|
|
115
115
|
},
|
|
116
116
|
"dependencies": {
|
|
117
|
-
"@mentra/cloud-protocol": "3.2.1-dev.
|
|
117
|
+
"@mentra/cloud-protocol": "3.2.1-dev.277"
|
|
118
118
|
},
|
|
119
119
|
"devDependencies": {
|
|
120
120
|
"@types/node": "^25.9.3",
|
|
@@ -12,9 +12,9 @@ export interface BluetoothSdkReleaseMetadata {
|
|
|
12
12
|
export const BLUETOOTH_SDK_RELEASE_METADATA: Readonly<BluetoothSdkReleaseMetadata> = Object.freeze({
|
|
13
13
|
"schemaVersion": 1,
|
|
14
14
|
"familyBaseVersion": "3.2.1",
|
|
15
|
-
"releaseIdentity": "3.2.1-dev.
|
|
16
|
-
"releaseSetId": "mentra-3.2.1-dev.
|
|
17
|
-
"sourceCommit": "
|
|
18
|
-
"otaManifestUrl": "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.2.1/mentra-live-ota-3.2.1-dev.
|
|
19
|
-
"otaManifestSha256": "
|
|
15
|
+
"releaseIdentity": "3.2.1-dev.277",
|
|
16
|
+
"releaseSetId": "mentra-3.2.1-dev.277",
|
|
17
|
+
"sourceCommit": "2668275c49728812fea5356f6df3e78e73f1ed87",
|
|
18
|
+
"otaManifestUrl": "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.2.1/mentra-live-ota-3.2.1-dev.277.json",
|
|
19
|
+
"otaManifestSha256": "527d30a6400d5accdbf83e2c4316ec6e30e2b7ef1d70b6fd54e7abcec1bcc183"
|
|
20
20
|
})
|