@mentra/bluetooth-sdk 3.2.0-dev.122 → 3.2.0-dev.127
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 +17 -3
- 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/BluetoothSdkModule.swift +2 -1
- package/ios/MentraBluetoothSDK.podspec +29 -11
- package/ios/Source/BluetoothSdkDefaults.swift +1 -1
- package/ios/Source/DeviceManager.swift +52 -12
- package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
- package/ios/Source/audio/PcmStreamPlayer.swift +14 -0
- package/ios/Source/internal/BleTraceLogger.swift +9 -2
- package/ios/Source/internal/BluetoothSdkAnalytics.swift +11 -2
- package/ios/Source/services/MacAudioDevices.swift +96 -0
- package/ios/Source/services/MacPhoneMic.swift +138 -0
- package/ios/Source/services/PhoneMic.swift +2 -0
- package/ios/Source/sgcs/G1.swift +2 -2
- package/ios/Source/sgcs/G2.swift +3 -3
- package/ios/Source/sgcs/MentraLive.swift +13 -6
- package/ios/Source/sgcs/Nimo.swift +3 -3
- package/ios/Source/utils/AudioSessionMonitor.swift +2 -0
- package/ios/Source/utils/JSCExperiment.swift +1 -1
- package/ios/Source/utils/MacAudioSessionMonitor.swift +18 -0
- package/ios/Source/utils/PhoneAudioMonitor.swift +13 -0
- package/ios/Source/utils/SdkImage.swift +37 -0
- package/ios/Tests/MacAudioDevicesTests.swift +32 -0
- package/ios/Tests/SdkImageTests.swift +53 -0
- package/package.json +1 -1
- package/src/generated/releaseMetadata.ts +5 -5
package/README.md
CHANGED
|
@@ -8,7 +8,8 @@ The package includes:
|
|
|
8
8
|
- React hooks under `@mentra/bluetooth-sdk/react` for common scan,
|
|
9
9
|
connection, status, and event lifecycles.
|
|
10
10
|
- Native Android code published as `com.mentraglass:bluetooth-sdk`.
|
|
11
|
-
- Native iOS code available as the `MentraBluetoothSDK` Swift package.
|
|
11
|
+
- Native iOS and macOS code available as the `MentraBluetoothSDK` Swift package.
|
|
12
|
+
- Native macOS Expo Modules adapter for `react-native-macos` hosts.
|
|
12
13
|
- An Expo config plugin that wires the native dependencies into generated Android and iOS projects.
|
|
13
14
|
|
|
14
15
|
Use a development build or production native build. Expo Go cannot load this package because the SDK contains native code.
|
|
@@ -19,6 +20,9 @@ Use a development build or production native build. Expo Go cannot load this pac
|
|
|
19
20
|
- Expo `49+` when using Expo.
|
|
20
21
|
- Android min SDK `28+`.
|
|
21
22
|
- iOS deployment target `15.1+`.
|
|
23
|
+
- Native macOS deployment target `13+`. The Starter Kit's separate
|
|
24
|
+
`react-native-macos` host requires macOS `14+` and a matching React Native / Expo
|
|
25
|
+
Modules toolchain; the iOS/Android config plugin does not generate a Mac host.
|
|
22
26
|
- A physical phone for real Bluetooth testing.
|
|
23
27
|
- Bluetooth permissions, plus Android location permission where BLE scanning requires it.
|
|
24
28
|
|
|
@@ -526,13 +530,15 @@ project, not the local Gradle entrypoint. The check script prepares
|
|
|
526
530
|
`mobile/android` and uses its Gradle wrapper with `-PmentraPublicSdk=true`,
|
|
527
531
|
matching the CI release workflow's public SDK dependency mode.
|
|
528
532
|
|
|
529
|
-
For bare native iOS apps, use the public SwiftPM repository:
|
|
533
|
+
For bare native iOS or macOS apps, use the public SwiftPM repository:
|
|
530
534
|
|
|
531
535
|
```text
|
|
532
536
|
https://github.com/Mentra-Community/mentra-bluetooth-sdk-ios.git
|
|
533
537
|
```
|
|
534
538
|
|
|
535
|
-
Select
|
|
539
|
+
Select a coordinated SDK version that includes your target platform, then add
|
|
540
|
+
the `MentraBluetoothSDK` product to your app target. Native macOS support is new
|
|
541
|
+
on `dev`; use the local source override until a supporting release is published.
|
|
536
542
|
|
|
537
543
|
For local SDK development, add this package folder directly in Xcode:
|
|
538
544
|
|
|
@@ -542,6 +548,14 @@ For local SDK development, add this package folder directly in Xcode:
|
|
|
542
548
|
|
|
543
549
|
The core Swift package intentionally excludes optional local STT, Nex/SwiftProtobuf, Vuzix/Ultralite, and tar.bz2 extraction code paths.
|
|
544
550
|
|
|
551
|
+
Native Mac hosts need Bluetooth and local-network usage descriptions, and
|
|
552
|
+
sandbox entitlements for Bluetooth and outbound networking. Add microphone
|
|
553
|
+
permission/audio-input capability for host microphone capture and network-server
|
|
554
|
+
capability for local SDK servers. macOS uses CoreAudio instead of
|
|
555
|
+
`AVAudioSession`; it does not provide iOS background modes or ANCS relay. See
|
|
556
|
+
[the macOS integration guide](../../../mintlify-docs/bluetooth-sdk/macos.mdx)
|
|
557
|
+
and the Starter Kit's `examples/macos` and `examples/react-native-macos` hosts.
|
|
558
|
+
|
|
545
559
|
Maintainers publishing the public SwiftPM mirror should follow
|
|
546
560
|
[RELEASING_IOS_SPM.md](./RELEASING_IOS_SPM.md).
|
|
547
561
|
|
|
@@ -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.0"
|
|
6
|
-
const val RELEASE_IDENTITY: String = "3.2.0-dev.
|
|
7
|
-
const val RELEASE_SET_ID: String = "mentra-3.2.0-dev.
|
|
8
|
-
const val SOURCE_COMMIT: String = "
|
|
9
|
-
const val OTA_MANIFEST_URL: String = "https://github.com/Mentra-Community/MentraOS/releases/download/mentra-builds-v3.2.0/mentra-live-ota-3.2.0-dev.
|
|
10
|
-
const val OTA_MANIFEST_SHA256: String = "
|
|
6
|
+
const val RELEASE_IDENTITY: String = "3.2.0-dev.127"
|
|
7
|
+
const val RELEASE_SET_ID: String = "mentra-3.2.0-dev.127"
|
|
8
|
+
const val SOURCE_COMMIT: String = "6f63b186e38f659faed60cbe5b9c95f457a32383"
|
|
9
|
+
const val OTA_MANIFEST_URL: String = "https://github.com/Mentra-Community/MentraOS/releases/download/mentra-builds-v3.2.0/mentra-live-ota-3.2.0-dev.127.json"
|
|
10
|
+
const val OTA_MANIFEST_SHA256: String = "426f58f142ca6d710089683c6e9462b0ce0c30d491b95747e960371e082f459c"
|
|
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.0",
|
|
4
|
-
"releaseIdentity": "3.2.0-dev.
|
|
5
|
-
"releaseSetId": "mentra-3.2.0-dev.
|
|
6
|
-
"sourceCommit": "
|
|
7
|
-
"otaManifestUrl": "https://github.com/Mentra-Community/MentraOS/releases/download/mentra-builds-v3.2.0/mentra-live-ota-3.2.0-dev.
|
|
8
|
-
"otaManifestSha256": "
|
|
4
|
+
"releaseIdentity": "3.2.0-dev.127",
|
|
5
|
+
"releaseSetId": "mentra-3.2.0-dev.127",
|
|
6
|
+
"sourceCommit": "6f63b186e38f659faed60cbe5b9c95f457a32383",
|
|
7
|
+
"otaManifestUrl": "https://github.com/Mentra-Community/MentraOS/releases/download/mentra-builds-v3.2.0/mentra-live-ota-3.2.0-dev.127.json",
|
|
8
|
+
"otaManifestSha256": "426f58f142ca6d710089683c6e9462b0ce0c30d491b95747e960371e082f459c"
|
|
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,wHAAwH;IAC1I,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.0\",\n \"releaseIdentity\": \"3.2.0-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,wHAAwH;IAC1I,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.0\",\n \"releaseIdentity\": \"3.2.0-dev.127\",\n \"releaseSetId\": \"mentra-3.2.0-dev.127\",\n \"sourceCommit\": \"6f63b186e38f659faed60cbe5b9c95f457a32383\",\n \"otaManifestUrl\": \"https://github.com/Mentra-Community/MentraOS/releases/download/mentra-builds-v3.2.0/mentra-live-ota-3.2.0-dev.127.json\",\n \"otaManifestSha256\": \"426f58f142ca6d710089683c6e9462b0ce0c30d491b95747e960371e082f459c\"\n})\n"]}
|
|
@@ -695,6 +695,7 @@ public class BluetoothSdkModule: Module, MentraBluetoothSDKDelegate {
|
|
|
695
695
|
|
|
696
696
|
// MARK: - STT Model Management
|
|
697
697
|
|
|
698
|
+
#if !os(macOS)
|
|
698
699
|
AsyncFunction("setSttModelDetails") { (path: String, languageCode: String) in
|
|
699
700
|
STTTools.setSttModelDetails(path, languageCode)
|
|
700
701
|
}
|
|
@@ -747,6 +748,7 @@ public class BluetoothSdkModule: Module, MentraBluetoothSDKDelegate {
|
|
|
747
748
|
speed: speed
|
|
748
749
|
)
|
|
749
750
|
}
|
|
751
|
+
#endif
|
|
750
752
|
}
|
|
751
753
|
|
|
752
754
|
@MainActor
|
|
@@ -936,4 +938,3 @@ private extension ConnectOptions {
|
|
|
936
938
|
|
|
937
939
|
|
|
938
940
|
|
|
939
|
-
|
|
@@ -12,7 +12,8 @@ Pod::Spec.new do |s|
|
|
|
12
12
|
s.author = package['author']
|
|
13
13
|
s.homepage = package['homepage']
|
|
14
14
|
s.platforms = {
|
|
15
|
-
:ios => '15.1'
|
|
15
|
+
:ios => '15.1',
|
|
16
|
+
:osx => '13.0'
|
|
16
17
|
}
|
|
17
18
|
s.swift_version = '5.9'
|
|
18
19
|
s.source = {
|
|
@@ -23,10 +24,10 @@ Pod::Spec.new do |s|
|
|
|
23
24
|
|
|
24
25
|
# External dependencies required by Bluetooth SDK native code
|
|
25
26
|
s.dependency 'ExpoModulesCore' if include_expo_adapter
|
|
26
|
-
s.dependency 'SWCompression', '~> 4.8.0'
|
|
27
|
-
s.dependency 'SwiftProtobuf', '~> 1.0'
|
|
28
|
-
s.dependency 'onnxruntime-objc', '1.18.0'
|
|
29
|
-
s.dependency 'UltraliteSDK'
|
|
27
|
+
s.ios.dependency 'SWCompression', '~> 4.8.0'
|
|
28
|
+
s.ios.dependency 'SwiftProtobuf', '~> 1.0'
|
|
29
|
+
s.ios.dependency 'onnxruntime-objc', '1.18.0'
|
|
30
|
+
s.ios.dependency 'UltraliteSDK'
|
|
30
31
|
|
|
31
32
|
# Swift/Objective-C compatibility
|
|
32
33
|
s.pod_target_xcconfig = {
|
|
@@ -39,13 +40,14 @@ Pod::Spec.new do |s|
|
|
|
39
40
|
# iOS frameworks required by Bluetooth SDK
|
|
40
41
|
ios_frameworks = ['AVFoundation', 'CoreBluetooth', 'UIKit', 'CoreGraphics']
|
|
41
42
|
ios_frameworks << 'Network' if include_expo_adapter
|
|
42
|
-
s.frameworks = ios_frameworks
|
|
43
|
+
s.ios.frameworks = ios_frameworks
|
|
44
|
+
s.osx.frameworks = ['AVFoundation', 'CoreBluetooth', 'CoreAudio', 'AudioToolbox', 'ImageIO', 'JavaScriptCore', 'Network']
|
|
43
45
|
|
|
44
46
|
# System libraries required by MentraOS
|
|
45
|
-
s.library = 'bz2'
|
|
47
|
+
s.ios.library = 'bz2'
|
|
46
48
|
|
|
47
49
|
# Vendored frameworks
|
|
48
|
-
s.vendored_frameworks = 'Packages/SherpaOnnx/sherpa-onnx.xcframework'
|
|
50
|
+
s.ios.vendored_frameworks = 'Packages/SherpaOnnx/sherpa-onnx.xcframework'
|
|
49
51
|
|
|
50
52
|
s.resource_bundles = {
|
|
51
53
|
'BluetoothSDKPrivacy' => ['Source/PrivacyInfo.xcprivacy']
|
|
@@ -60,7 +62,7 @@ Pod::Spec.new do |s|
|
|
|
60
62
|
"Packages/SherpaOnnx/sherpa-onnx.xcframework/Headers/**/*.{h,hpp}",
|
|
61
63
|
"Packages/libbz2/shim.h"
|
|
62
64
|
]
|
|
63
|
-
|
|
65
|
+
adapter_source_files = [
|
|
64
66
|
"BluetoothSdkModule.swift",
|
|
65
67
|
"LocalPhotoUploadServer.swift",
|
|
66
68
|
"MentraPhotoReceiverModule.swift",
|
|
@@ -68,8 +70,24 @@ Pod::Spec.new do |s|
|
|
|
68
70
|
"LocalOtaServer.swift",
|
|
69
71
|
"BackgroundOtaArtifactDownloader.swift",
|
|
70
72
|
"MentraOtaServerModule.swift"
|
|
71
|
-
]
|
|
72
|
-
|
|
73
|
+
]
|
|
74
|
+
native_source_files.concat(adapter_source_files) if include_expo_adapter
|
|
75
|
+
s.ios.source_files = native_source_files
|
|
76
|
+
s.osx.source_files = [
|
|
77
|
+
'Source/**/*.{h,m,mm,swift,hpp,cpp,c}',
|
|
78
|
+
'Packages/CoreObjC/**/*.{h,m,mm,hpp,cpp,c}'
|
|
79
|
+
] + (include_expo_adapter ? adapter_source_files : [])
|
|
80
|
+
s.osx.exclude_files = [
|
|
81
|
+
'Source/Bridging-Header.h',
|
|
82
|
+
'Source/sgcs/Mach1.swift',
|
|
83
|
+
'Source/sgcs/MentraNex.swift',
|
|
84
|
+
'Source/sgcs/mentraos_ble.pb.swift',
|
|
85
|
+
'Source/stt/**/*',
|
|
86
|
+
'Source/tts/**/*',
|
|
87
|
+
'Source/utils/TarBz2Extractor.swift',
|
|
88
|
+
'Source/BridgeModule.{h,m}',
|
|
89
|
+
'Source/Bridge.m'
|
|
90
|
+
]
|
|
73
91
|
|
|
74
92
|
# Explicitly mark C++ headers and internal headers as private to prevent exposure in public interface
|
|
75
93
|
s.private_header_files = [
|
|
@@ -11,7 +11,7 @@ enum BluetoothSdkDefaults {
|
|
|
11
11
|
static let voiceActivityDetectionEnabled = false
|
|
12
12
|
static let loudnessGateEnabled = true
|
|
13
13
|
private static let infoSdkVersionKey = "MentraBluetoothSdkVersion"
|
|
14
|
-
private static let swiftPackageSdkVersion = "3.2.0-dev.
|
|
14
|
+
private static let swiftPackageSdkVersion = "3.2.0-dev.127"
|
|
15
15
|
private static let swiftPackageSdkVersionPlaceholder = "__MENTRA" + "_BLUETOOTH_SDK_VERSION__"
|
|
16
16
|
|
|
17
17
|
private static func normalizedSdkVersion(_ value: String?) -> String? {
|
|
@@ -9,7 +9,12 @@ import AVFoundation
|
|
|
9
9
|
import Combine
|
|
10
10
|
import CoreBluetooth
|
|
11
11
|
import Foundation
|
|
12
|
+
#if os(macOS)
|
|
13
|
+
import CoreAudio
|
|
14
|
+
#endif
|
|
15
|
+
#if canImport(UIKit)
|
|
12
16
|
import UIKit
|
|
17
|
+
#endif
|
|
13
18
|
#if SWIFT_PACKAGE
|
|
14
19
|
import MentraBluetoothSDKCoreObjC
|
|
15
20
|
#endif
|
|
@@ -43,6 +48,9 @@ struct ViewState {
|
|
|
43
48
|
// MARK: - Unique (iOS)
|
|
44
49
|
|
|
45
50
|
private var cancellables = Set<AnyCancellable>()
|
|
51
|
+
#if os(macOS)
|
|
52
|
+
private var audioRouteObserver: MacAudioRouteObserver?
|
|
53
|
+
#endif
|
|
46
54
|
var sendStateWorkItem: DispatchWorkItem?
|
|
47
55
|
let sendStateQueue = DispatchQueue(label: "sendStateQueue", qos: .userInitiated)
|
|
48
56
|
|
|
@@ -51,6 +59,7 @@ struct ViewState {
|
|
|
51
59
|
* Attempts to automatically activate Mentra Live as the system audio device
|
|
52
60
|
* If not paired yet, prompts user to pair in Settings
|
|
53
61
|
*/
|
|
62
|
+
#if !os(macOS)
|
|
54
63
|
func setupAudioPairing(deviceName _: String) {
|
|
55
64
|
// Don't configure audio session - PhoneMic.swift handles that
|
|
56
65
|
// Just check if audio session supports Bluetooth (informational only)
|
|
@@ -83,6 +92,7 @@ struct ViewState {
|
|
|
83
92
|
// Not found in availableInputs - not paired yet
|
|
84
93
|
|
|
85
94
|
// Start monitoring for when user pairs manually
|
|
95
|
+
#if !os(macOS)
|
|
86
96
|
AudioSessionMonitor.startMonitoring(devicePattern: audioDevicePattern) {
|
|
87
97
|
[weak self] (connected: Bool, _: String?) in
|
|
88
98
|
guard let self = self else { return }
|
|
@@ -96,9 +106,12 @@ struct ViewState {
|
|
|
96
106
|
self.glassesBluetoothClassicConnected = false
|
|
97
107
|
}
|
|
98
108
|
}
|
|
109
|
+
#endif
|
|
99
110
|
}
|
|
100
111
|
}
|
|
101
112
|
|
|
113
|
+
#endif
|
|
114
|
+
|
|
102
115
|
// MARK: - End Unique
|
|
103
116
|
|
|
104
117
|
// MARK: - Properties
|
|
@@ -314,7 +327,7 @@ struct ViewState {
|
|
|
314
327
|
private var micReinitTimer: Timer?
|
|
315
328
|
|
|
316
329
|
/// STT:
|
|
317
|
-
#if !SWIFT_PACKAGE || MENTRA_FEATURE_LOCAL_STT
|
|
330
|
+
#if !os(macOS) && (!SWIFT_PACKAGE || MENTRA_FEATURE_LOCAL_STT)
|
|
318
331
|
private var transcriber: SherpaOnnxTranscriber?
|
|
319
332
|
#endif
|
|
320
333
|
|
|
@@ -351,7 +364,7 @@ struct ViewState {
|
|
|
351
364
|
// MemoryMonitor.start()
|
|
352
365
|
|
|
353
366
|
// Initialize SherpaOnnx Transcriber
|
|
354
|
-
#if !SWIFT_PACKAGE || MENTRA_FEATURE_LOCAL_STT
|
|
367
|
+
#if !os(macOS) && (!SWIFT_PACKAGE || MENTRA_FEATURE_LOCAL_STT)
|
|
355
368
|
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
|
356
369
|
let window = windowScene.windows.first,
|
|
357
370
|
let rootViewController = window.rootViewController
|
|
@@ -500,7 +513,7 @@ struct ViewState {
|
|
|
500
513
|
handleSendingPcm(pcmData)
|
|
501
514
|
|
|
502
515
|
// Send PCM to local transcriber.
|
|
503
|
-
#if !SWIFT_PACKAGE || MENTRA_FEATURE_LOCAL_STT
|
|
516
|
+
#if !os(macOS) && (!SWIFT_PACKAGE || MENTRA_FEATURE_LOCAL_STT)
|
|
504
517
|
if shouldSendTranscript || localSttFallbackActive {
|
|
505
518
|
transcriber?.acceptAudio(pcm16le: pcmData)
|
|
506
519
|
}
|
|
@@ -517,11 +530,13 @@ struct ViewState {
|
|
|
517
530
|
|
|
518
531
|
var phoneMicUnavailable = systemMicUnavailable
|
|
519
532
|
|
|
533
|
+
#if !os(macOS)
|
|
520
534
|
let appState = UIApplication.shared.applicationState
|
|
521
535
|
if appState == .background {
|
|
522
536
|
// Bridge.log("App is in background - onboard mic unavailable to start!")
|
|
523
537
|
phoneMicUnavailable = true
|
|
524
538
|
}
|
|
539
|
+
#endif
|
|
525
540
|
|
|
526
541
|
if micEnabled {
|
|
527
542
|
for micMode in micRanking {
|
|
@@ -710,12 +725,12 @@ struct ViewState {
|
|
|
710
725
|
} else if wearable.contains(DeviceTypes.FRAME) {
|
|
711
726
|
// sgc = FrameManager()
|
|
712
727
|
}
|
|
713
|
-
#if !SWIFT_PACKAGE || MENTRA_FEATURE_NEX
|
|
728
|
+
#if !os(macOS) && (!SWIFT_PACKAGE || MENTRA_FEATURE_NEX)
|
|
714
729
|
if sgc == nil && wearable.contains(DeviceTypes.NEX) {
|
|
715
730
|
sgc = MentraNexSGC.getInstance()
|
|
716
731
|
}
|
|
717
732
|
#endif
|
|
718
|
-
#if !SWIFT_PACKAGE || MENTRA_FEATURE_VUZIX
|
|
733
|
+
#if !os(macOS) && (!SWIFT_PACKAGE || MENTRA_FEATURE_VUZIX)
|
|
719
734
|
if sgc == nil {
|
|
720
735
|
if wearable.contains(DeviceTypes.MACH1) {
|
|
721
736
|
sgc = Mach1()
|
|
@@ -927,6 +942,10 @@ struct ViewState {
|
|
|
927
942
|
return
|
|
928
943
|
}
|
|
929
944
|
|
|
945
|
+
#if os(macOS)
|
|
946
|
+
glassesBluetoothClassicConnected = AudioSessionMonitor.isAudioDeviceConnected(devicePattern: audioDevicePattern)
|
|
947
|
+
otherBtConnected = AudioSessionMonitor.isOtherAudioDeviceConnected(devicePattern: audioDevicePattern)
|
|
948
|
+
#else
|
|
930
949
|
// check if the device disconnected:
|
|
931
950
|
let isConnected = AudioSessionMonitor.isAudioDeviceConnected(
|
|
932
951
|
devicePattern: audioDevicePattern
|
|
@@ -948,17 +967,15 @@ struct ViewState {
|
|
|
948
967
|
|
|
949
968
|
let isPaired = AudioSessionMonitor.isDevicePaired(devicePattern: audioDevicePattern)
|
|
950
969
|
if isPaired {
|
|
951
|
-
|
|
952
|
-
let deviceName = session.availableInputs?.first(where: {
|
|
953
|
-
$0.portName.localizedCaseInsensitiveContains(audioDevicePattern)
|
|
954
|
-
})?.portName
|
|
955
|
-
Bridge.log("MAN: Successfully detected newly paired device '\(deviceName)'")
|
|
970
|
+
Bridge.log("MAN: Successfully detected newly paired device '\(audioDevicePattern)'")
|
|
956
971
|
glassesBluetoothClassicConnected = true
|
|
957
972
|
} else {
|
|
958
973
|
glassesBluetoothClassicConnected = false
|
|
959
974
|
}
|
|
975
|
+
#endif
|
|
960
976
|
}
|
|
961
977
|
|
|
978
|
+
#if !os(macOS)
|
|
962
979
|
func onRouteChange(
|
|
963
980
|
reason: AVAudioSession.RouteChangeReason, availableInputs: [AVAudioSessionPortDescription]
|
|
964
981
|
) {
|
|
@@ -980,6 +997,7 @@ struct ViewState {
|
|
|
980
997
|
|
|
981
998
|
updateMicState()
|
|
982
999
|
}
|
|
1000
|
+
#endif
|
|
983
1001
|
|
|
984
1002
|
func onInterruption(began: Bool) {
|
|
985
1003
|
Bridge.log("MAN: Interruption: \(began)")
|
|
@@ -988,7 +1006,7 @@ struct ViewState {
|
|
|
988
1006
|
}
|
|
989
1007
|
|
|
990
1008
|
func restartTranscriber() {
|
|
991
|
-
#if !SWIFT_PACKAGE || MENTRA_FEATURE_LOCAL_STT
|
|
1009
|
+
#if !os(macOS) && (!SWIFT_PACKAGE || MENTRA_FEATURE_LOCAL_STT)
|
|
992
1010
|
Bridge.log("MAN: Restarting SherpaOnnxTranscriber via command")
|
|
993
1011
|
transcriber?.restart()
|
|
994
1012
|
#else
|
|
@@ -1054,6 +1072,16 @@ struct ViewState {
|
|
|
1054
1072
|
}
|
|
1055
1073
|
|
|
1056
1074
|
// check current audio device:
|
|
1075
|
+
#if os(macOS)
|
|
1076
|
+
audioRouteObserver = MacAudioRouteObserver(selectors: [kAudioHardwarePropertyDevices,
|
|
1077
|
+
kAudioHardwarePropertyDefaultInputDevice, kAudioHardwarePropertyDefaultOutputDevice]) { [weak self] in
|
|
1078
|
+
Task { @MainActor in
|
|
1079
|
+
guard let self, self.audioRouteObserver != nil else { return }
|
|
1080
|
+
self.checkCurrentAudioDevice()
|
|
1081
|
+
self.updateMicState()
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
#endif
|
|
1057
1085
|
checkCurrentAudioDevice()
|
|
1058
1086
|
|
|
1059
1087
|
// save the default_wearable now that we're connected:
|
|
@@ -1146,6 +1174,9 @@ struct ViewState {
|
|
|
1146
1174
|
|
|
1147
1175
|
func handleDeviceDisconnected() {
|
|
1148
1176
|
Bridge.log("MAN: Device disconnected")
|
|
1177
|
+
#if os(macOS)
|
|
1178
|
+
audioRouteObserver = nil
|
|
1179
|
+
#endif
|
|
1149
1180
|
resetSystemTimeSync()
|
|
1150
1181
|
resetMicHealth()
|
|
1151
1182
|
DeviceStore.shared.apply("glasses", "headUp", false)
|
|
@@ -1825,6 +1856,11 @@ struct ViewState {
|
|
|
1825
1856
|
}
|
|
1826
1857
|
|
|
1827
1858
|
func disconnect(clearPendingConnection: Bool = true) {
|
|
1859
|
+
#if os(macOS)
|
|
1860
|
+
audioRouteObserver = nil
|
|
1861
|
+
glassesBluetoothClassicConnected = false
|
|
1862
|
+
otherBtConnected = false
|
|
1863
|
+
#endif
|
|
1828
1864
|
sgc?.clearDisplay() // clear the screen
|
|
1829
1865
|
sgc?.disconnect()
|
|
1830
1866
|
sgc = nil // Clear the SGC reference after disconnect
|
|
@@ -1932,8 +1968,12 @@ struct ViewState {
|
|
|
1932
1968
|
}
|
|
1933
1969
|
|
|
1934
1970
|
func cleanup() {
|
|
1971
|
+
#if os(macOS)
|
|
1972
|
+
audioRouteObserver = nil
|
|
1973
|
+
PhoneMic.shared.cleanup()
|
|
1974
|
+
#endif
|
|
1935
1975
|
// Clean up transcriber resources
|
|
1936
|
-
#if !SWIFT_PACKAGE || MENTRA_FEATURE_LOCAL_STT
|
|
1976
|
+
#if !os(macOS) && (!SWIFT_PACKAGE || MENTRA_FEATURE_LOCAL_STT)
|
|
1937
1977
|
transcriber?.shutdown()
|
|
1938
1978
|
transcriber = nil
|
|
1939
1979
|
#endif
|
|
@@ -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.0"
|
|
6
|
-
static let releaseIdentity = "3.2.0-dev.
|
|
7
|
-
static let releaseSetId = "mentra-3.2.0-dev.
|
|
8
|
-
static let sourceCommit = "
|
|
9
|
-
static let otaManifestUrl = "https://github.com/Mentra-Community/MentraOS/releases/download/mentra-builds-v3.2.0/mentra-live-ota-3.2.0-dev.
|
|
10
|
-
static let otaManifestSha256 = "
|
|
6
|
+
static let releaseIdentity = "3.2.0-dev.127"
|
|
7
|
+
static let releaseSetId = "mentra-3.2.0-dev.127"
|
|
8
|
+
static let sourceCommit = "6f63b186e38f659faed60cbe5b9c95f457a32383"
|
|
9
|
+
static let otaManifestUrl = "https://github.com/Mentra-Community/MentraOS/releases/download/mentra-builds-v3.2.0/mentra-live-ota-3.2.0-dev.127.json"
|
|
10
|
+
static let otaManifestSha256 = "426f58f142ca6d710089683c6e9462b0ce0c30d491b95747e960371e082f459c"
|
|
11
11
|
}
|
|
@@ -261,6 +261,17 @@ final class PcmStreamPlayer: @unchecked Sendable {
|
|
|
261
261
|
|
|
262
262
|
private func installAudioSessionObservers() {
|
|
263
263
|
let center = NotificationCenter.default
|
|
264
|
+
#if os(macOS)
|
|
265
|
+
observers.append(center.addObserver(
|
|
266
|
+
forName: .AVAudioEngineConfigurationChange, object: engine, queue: nil
|
|
267
|
+
) { [weak self] _ in
|
|
268
|
+
self?.stateQueue.async {
|
|
269
|
+
// A device change invalidates scheduled buffers. Reject pending writes/close
|
|
270
|
+
// instead of leaving callers waiting for callbacks that cannot arrive.
|
|
271
|
+
self?.fail(PcmStreamError.native("Audio device changed; reopen the PCM stream"))
|
|
272
|
+
}
|
|
273
|
+
})
|
|
274
|
+
#else
|
|
264
275
|
observers.append(
|
|
265
276
|
center.addObserver(
|
|
266
277
|
forName: AVAudioSession.interruptionNotification,
|
|
@@ -294,6 +305,7 @@ final class PcmStreamPlayer: @unchecked Sendable {
|
|
|
294
305
|
}
|
|
295
306
|
}
|
|
296
307
|
)
|
|
308
|
+
#endif
|
|
297
309
|
}
|
|
298
310
|
|
|
299
311
|
private func removeAudioSessionObservers() {
|
|
@@ -301,6 +313,7 @@ final class PcmStreamPlayer: @unchecked Sendable {
|
|
|
301
313
|
observers.removeAll()
|
|
302
314
|
}
|
|
303
315
|
|
|
316
|
+
#if !os(macOS)
|
|
304
317
|
private func handleInterruption(_ notification: Notification) {
|
|
305
318
|
guard
|
|
306
319
|
let raw = notification.userInfo?[AVAudioSessionInterruptionTypeKey] as? UInt,
|
|
@@ -321,6 +334,7 @@ final class PcmStreamPlayer: @unchecked Sendable {
|
|
|
321
334
|
break
|
|
322
335
|
}
|
|
323
336
|
}
|
|
337
|
+
#endif
|
|
324
338
|
}
|
|
325
339
|
|
|
326
340
|
/// Process-wide registry matching the Android PCM stream bridge contract.
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import Foundation
|
|
2
2
|
import os.log
|
|
3
|
+
#if canImport(UIKit)
|
|
3
4
|
import UIKit
|
|
5
|
+
#endif
|
|
4
6
|
|
|
5
7
|
enum BleTraceLogger {
|
|
6
8
|
private static let log = OSLog(subsystem: "com.mentra.bluetoothsdk", category: "MentraBleTrace")
|
|
@@ -66,9 +68,14 @@ enum BleTraceLogger {
|
|
|
66
68
|
"event": event,
|
|
67
69
|
"component": component,
|
|
68
70
|
"pid": ProcessInfo.processInfo.processIdentifier,
|
|
69
|
-
"model": UIDevice.current.model,
|
|
70
|
-
"systemVersion": UIDevice.current.systemVersion,
|
|
71
71
|
]
|
|
72
|
+
#if os(macOS)
|
|
73
|
+
payload["model"] = "Mac"
|
|
74
|
+
payload["systemVersion"] = ProcessInfo.processInfo.operatingSystemVersionString
|
|
75
|
+
#else
|
|
76
|
+
payload["model"] = UIDevice.current.model
|
|
77
|
+
payload["systemVersion"] = UIDevice.current.systemVersion
|
|
78
|
+
#endif
|
|
72
79
|
for (key, value) in extra {
|
|
73
80
|
payload[key] = value
|
|
74
81
|
}
|
|
@@ -8,7 +8,11 @@ public struct BluetoothSdkAnalyticsConfiguration {
|
|
|
8
8
|
|
|
9
9
|
public init(enabled: Bool = true) {
|
|
10
10
|
self.enabled = enabled
|
|
11
|
+
#if os(macOS)
|
|
12
|
+
surface = "macos"
|
|
13
|
+
#else
|
|
11
14
|
surface = "ios"
|
|
15
|
+
#endif
|
|
12
16
|
}
|
|
13
17
|
|
|
14
18
|
var isReady: Bool {
|
|
@@ -141,14 +145,19 @@ final class BluetoothSdkAnalytics {
|
|
|
141
145
|
}
|
|
142
146
|
|
|
143
147
|
private func baseProperties(configuration: BluetoothSdkAnalyticsConfiguration) -> [String: Any] {
|
|
148
|
+
#if os(macOS)
|
|
149
|
+
let platform = "macos"
|
|
150
|
+
#else
|
|
151
|
+
let platform = "ios"
|
|
152
|
+
#endif
|
|
144
153
|
var properties: [String: Any] = [
|
|
145
154
|
"$process_person_profile": false,
|
|
146
155
|
"event_source": "mentra_bluetooth_sdk",
|
|
147
|
-
"sdk_platform":
|
|
156
|
+
"sdk_platform": platform,
|
|
148
157
|
"sdk_surface": configuration.surface,
|
|
149
158
|
"app_identifier": Bundle.main.bundleIdentifier ?? "",
|
|
150
159
|
"app_bundle_identifier": Bundle.main.bundleIdentifier ?? "",
|
|
151
|
-
"os_platform":
|
|
160
|
+
"os_platform": platform,
|
|
152
161
|
"os_version": ProcessInfo.processInfo.operatingSystemVersionString,
|
|
153
162
|
]
|
|
154
163
|
if let sdkVersion = BluetoothSdkDefaults.sdkVersion {
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
#if os(macOS)
|
|
2
|
+
import CoreAudio
|
|
3
|
+
import Foundation
|
|
4
|
+
|
|
5
|
+
struct MacAudioDevice {
|
|
6
|
+
let id: AudioDeviceID
|
|
7
|
+
let name: String
|
|
8
|
+
let transport: UInt32
|
|
9
|
+
let hasInput: Bool
|
|
10
|
+
|
|
11
|
+
var isBluetooth: Bool {
|
|
12
|
+
transport == kAudioDeviceTransportTypeBluetooth || transport == kAudioDeviceTransportTypeBluetoothLE
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
static func input(for mode: String, devices: [MacAudioDevice], defaultInput: AudioDeviceID?) -> AudioDeviceID? {
|
|
16
|
+
let inputs = devices.filter(\.hasInput)
|
|
17
|
+
switch mode {
|
|
18
|
+
case MicTypes.PHONE_INTERNAL:
|
|
19
|
+
let builtIn = inputs.filter { $0.transport == kAudioDeviceTransportTypeBuiltIn }
|
|
20
|
+
return builtIn.first { $0.id == defaultInput }?.id ?? builtIn.map(\.id).min()
|
|
21
|
+
case MicTypes.BLUETOOTH, MicTypes.BLUETOOTH_CLASSIC:
|
|
22
|
+
// Respect the user's Sound settings; never pick an arbitrary nearby headset.
|
|
23
|
+
return inputs.first { $0.id == defaultInput && $0.isBluetooth }?.id
|
|
24
|
+
default:
|
|
25
|
+
return nil
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
static var available: [MacAudioDevice] {
|
|
30
|
+
var address = address(kAudioHardwarePropertyDevices)
|
|
31
|
+
var size: UInt32 = 0
|
|
32
|
+
guard AudioObjectGetPropertyDataSize(AudioObjectID(kAudioObjectSystemObject), &address, 0, nil, &size) == noErr
|
|
33
|
+
else { return [] }
|
|
34
|
+
var ids = [AudioDeviceID](repeating: 0, count: Int(size) / MemoryLayout<AudioDeviceID>.size)
|
|
35
|
+
guard AudioObjectGetPropertyData(AudioObjectID(kAudioObjectSystemObject), &address, 0, nil, &size, &ids) == noErr
|
|
36
|
+
else { return [] }
|
|
37
|
+
return ids.compactMap { id in
|
|
38
|
+
var nameAddress = self.address(kAudioObjectPropertyName)
|
|
39
|
+
var name: Unmanaged<CFString>?
|
|
40
|
+
var nameSize = UInt32(MemoryLayout<Unmanaged<CFString>?>.size)
|
|
41
|
+
guard AudioObjectGetPropertyData(id, &nameAddress, 0, nil, &nameSize, &name) == noErr
|
|
42
|
+
else { return nil }
|
|
43
|
+
guard let name = name?.takeRetainedValue() else { return nil }
|
|
44
|
+
var inputAddress = self.address(kAudioDevicePropertyStreams, scope: kAudioObjectPropertyScopeInput)
|
|
45
|
+
var inputSize: UInt32 = 0
|
|
46
|
+
let hasInput = AudioObjectGetPropertyDataSize(id, &inputAddress, 0, nil, &inputSize) == noErr && inputSize > 0
|
|
47
|
+
return MacAudioDevice(id: id, name: name as String,
|
|
48
|
+
transport: value(id, kAudioDevicePropertyTransportType) ?? 0, hasInput: hasInput)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
static var defaultInput: AudioDeviceID? {
|
|
53
|
+
value(AudioObjectID(kAudioObjectSystemObject), kAudioHardwarePropertyDefaultInputDevice)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
static var defaultOutput: AudioDeviceID? {
|
|
57
|
+
value(AudioObjectID(kAudioObjectSystemObject), kAudioHardwarePropertyDefaultOutputDevice)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
static func value(_ object: AudioObjectID, _ selector: AudioObjectPropertySelector) -> UInt32? {
|
|
61
|
+
var address = address(selector)
|
|
62
|
+
var value: UInt32 = 0
|
|
63
|
+
var size = UInt32(MemoryLayout<UInt32>.size)
|
|
64
|
+
return AudioObjectGetPropertyData(object, &address, 0, nil, &size, &value) == noErr ? value : nil
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
static func address(_ selector: AudioObjectPropertySelector,
|
|
68
|
+
scope: AudioObjectPropertyScope = kAudioObjectPropertyScopeGlobal) -> AudioObjectPropertyAddress
|
|
69
|
+
{
|
|
70
|
+
AudioObjectPropertyAddress(mSelector: selector, mScope: scope, mElement: kAudioObjectPropertyElementMain)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/// Observe route changes without selecting a system-wide audio device.
|
|
75
|
+
final class MacAudioRouteObserver {
|
|
76
|
+
private let listener: AudioObjectPropertyListenerBlock
|
|
77
|
+
private var selectors: [AudioObjectPropertySelector] = []
|
|
78
|
+
|
|
79
|
+
init(selectors: [AudioObjectPropertySelector], onChange: @escaping () -> Void) {
|
|
80
|
+
listener = { _, _ in onChange() }
|
|
81
|
+
for selector in selectors {
|
|
82
|
+
var address = MacAudioDevice.address(selector)
|
|
83
|
+
if AudioObjectAddPropertyListenerBlock(AudioObjectID(kAudioObjectSystemObject), &address, .main, listener) == noErr {
|
|
84
|
+
self.selectors.append(selector)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
deinit {
|
|
90
|
+
for selector in selectors {
|
|
91
|
+
var address = MacAudioDevice.address(selector)
|
|
92
|
+
AudioObjectRemovePropertyListenerBlock(AudioObjectID(kAudioObjectSystemObject), &address, .main, listener)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
#endif
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
#if os(macOS)
|
|
2
|
+
import AudioUnit
|
|
3
|
+
import AVFoundation
|
|
4
|
+
import CoreAudio
|
|
5
|
+
import Foundation
|
|
6
|
+
|
|
7
|
+
@MainActor
|
|
8
|
+
final class PhoneMic {
|
|
9
|
+
static let shared = PhoneMic()
|
|
10
|
+
private var engine: AVAudioEngine?
|
|
11
|
+
private var configurationObserver: NSObjectProtocol?
|
|
12
|
+
private var routeObserver: MacAudioRouteObserver?
|
|
13
|
+
private var currentMode = ""
|
|
14
|
+
private var activeDevice: AudioDeviceID?
|
|
15
|
+
var isRecording: Bool {
|
|
16
|
+
engine?.isRunning == true
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
func checkPermissions() -> Bool {
|
|
20
|
+
AVCaptureDevice.authorizationStatus(for: .audio) == .authorized
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
func requestPermissions() async -> Bool {
|
|
24
|
+
checkPermissions()
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
func getAvailableInputDevices() -> [String: String] {
|
|
28
|
+
Dictionary(uniqueKeysWithValues: MacAudioDevice.available.filter(\.hasInput).map { (String($0.id), $0.name) })
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
func isRecordingWithMode(_ mode: String) -> Bool {
|
|
32
|
+
isRecording && currentMode == mode
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
func startMode(_ mode: String) -> Bool {
|
|
36
|
+
if isRecordingWithMode(mode) { return true }
|
|
37
|
+
guard !isRecording, checkPermissions() else { return false }
|
|
38
|
+
guard let device = MacAudioDevice.input(for: mode, devices: MacAudioDevice.available,
|
|
39
|
+
defaultInput: MacAudioDevice.defaultInput) else { return false }
|
|
40
|
+
return start(device: device, mode: mode)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
func startRecording() -> Bool {
|
|
44
|
+
startMode(MicTypes.PHONE_INTERNAL)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
private func start(device: AudioDeviceID, mode: String) -> Bool {
|
|
48
|
+
let engine = AVAudioEngine()
|
|
49
|
+
let input = engine.inputNode
|
|
50
|
+
guard let unit = input.audioUnit else { return false }
|
|
51
|
+
var device = device
|
|
52
|
+
guard AudioUnitSetProperty(unit, kAudioOutputUnitProperty_CurrentDevice, kAudioUnitScope_Global,
|
|
53
|
+
0, &device, UInt32(MemoryLayout<AudioDeviceID>.size)) == noErr
|
|
54
|
+
else { return false }
|
|
55
|
+
let format = input.outputFormat(forBus: 0)
|
|
56
|
+
guard format.sampleRate > 0, format.channelCount > 0,
|
|
57
|
+
let output = AVAudioFormat(commonFormat: .pcmFormatInt16, sampleRate: 16000, channels: 1, interleaved: false),
|
|
58
|
+
let converter = AVAudioConverter(from: format, to: output)
|
|
59
|
+
else { return false }
|
|
60
|
+
input.installTap(onBus: 0, bufferSize: 1024, format: format) { [weak self, weak engine] buffer, _ in
|
|
61
|
+
let capacity = AVAudioFrameCount(ceil(Double(buffer.frameLength) * output.sampleRate / format.sampleRate))
|
|
62
|
+
guard let converted = AVAudioPCMBuffer(pcmFormat: output, frameCapacity: capacity) else { return }
|
|
63
|
+
var supplied = false
|
|
64
|
+
var error: NSError?
|
|
65
|
+
let result = converter.convert(to: converted, error: &error) { _, status in
|
|
66
|
+
guard !supplied else { status.pointee = .noDataNow; return nil }
|
|
67
|
+
supplied = true
|
|
68
|
+
status.pointee = .haveData
|
|
69
|
+
return buffer
|
|
70
|
+
}
|
|
71
|
+
guard result != .error, error == nil, converted.frameLength > 0,
|
|
72
|
+
let samples = converted.int16ChannelData?[0] else { return }
|
|
73
|
+
let pcm = Data(bytes: samples, count: Int(converted.frameLength) * MemoryLayout<Int16>.size)
|
|
74
|
+
Task { @MainActor [weak self, weak engine] in
|
|
75
|
+
guard let engine, self?.engine === engine else { return }
|
|
76
|
+
DeviceManager.shared.handlePcm(pcm)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
do {
|
|
80
|
+
try engine.start()
|
|
81
|
+
} catch {
|
|
82
|
+
input.removeTap(onBus: 0)
|
|
83
|
+
Bridge.log("MIC: macOS input failed: \(error.localizedDescription)")
|
|
84
|
+
return false
|
|
85
|
+
}
|
|
86
|
+
self.engine = engine
|
|
87
|
+
activeDevice = device
|
|
88
|
+
currentMode = mode
|
|
89
|
+
routeObserver = MacAudioRouteObserver(selectors: [kAudioHardwarePropertyDevices, kAudioHardwarePropertyDefaultInputDevice]) { [weak self, weak engine] in
|
|
90
|
+
Task { @MainActor in
|
|
91
|
+
guard let self, let engine, self.engine === engine else { return }
|
|
92
|
+
let desired = MacAudioDevice.input(for: self.currentMode, devices: MacAudioDevice.available,
|
|
93
|
+
defaultInput: MacAudioDevice.defaultInput)
|
|
94
|
+
guard desired != self.activeDevice else { return }
|
|
95
|
+
self.stopRecording()
|
|
96
|
+
DeviceManager.shared.updateMicState()
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
configurationObserver = NotificationCenter.default.addObserver(
|
|
100
|
+
forName: .AVAudioEngineConfigurationChange, object: engine, queue: .main
|
|
101
|
+
) { [weak self, weak engine] _ in
|
|
102
|
+
Task { @MainActor in
|
|
103
|
+
guard let engine, self?.engine === engine else { return }
|
|
104
|
+
self?.stopRecording()
|
|
105
|
+
DeviceManager.shared.updateMicState()
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return true
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
func getActiveInputDevice() -> String? {
|
|
112
|
+
MacAudioDevice.available.first(where: { $0.id == activeDevice })?.name
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
func stopMode(_ mode: String) -> Bool {
|
|
116
|
+
guard currentMode == mode else { return false }
|
|
117
|
+
stopRecording()
|
|
118
|
+
return true
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
func stopRecording() {
|
|
122
|
+
routeObserver = nil
|
|
123
|
+
if let configurationObserver {
|
|
124
|
+
NotificationCenter.default.removeObserver(configurationObserver)
|
|
125
|
+
}
|
|
126
|
+
configurationObserver = nil
|
|
127
|
+
engine?.inputNode.removeTap(onBus: 0)
|
|
128
|
+
engine?.stop()
|
|
129
|
+
engine = nil
|
|
130
|
+
currentMode = ""
|
|
131
|
+
activeDevice = nil
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
func cleanup() {
|
|
135
|
+
stopRecording()
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
#endif
|
package/ios/Source/sgcs/G1.swift
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import Combine
|
|
9
9
|
import CoreBluetooth
|
|
10
10
|
import Foundation
|
|
11
|
-
import
|
|
11
|
+
import CoreGraphics
|
|
12
12
|
|
|
13
13
|
extension Data {
|
|
14
14
|
func chunked(into size: Int) -> [Data] {
|
|
@@ -1822,7 +1822,7 @@ extension G1 {
|
|
|
1822
1822
|
/// Mirrors G2.convertToG2Bmp() for local miniapp bitmap display support.
|
|
1823
1823
|
/// G1 uses 1-bit monochrome BMP at 640x400 (576x135 active area).
|
|
1824
1824
|
func convertToG1Bmp(_ imageData: Data, width: Int = 576, height: Int = 135) -> Data? {
|
|
1825
|
-
guard let image =
|
|
1825
|
+
guard let image = SdkImage(data: imageData), let cgImage = image.cgImage else {
|
|
1826
1826
|
Bridge.log("G1: convertToG1Bmp - could not decode image")
|
|
1827
1827
|
return nil
|
|
1828
1828
|
}
|
package/ios/Source/sgcs/G2.swift
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import Combine
|
|
10
10
|
import CoreBluetooth
|
|
11
11
|
import Foundation
|
|
12
|
-
import
|
|
12
|
+
import CoreGraphics
|
|
13
13
|
|
|
14
14
|
/// Keep a low-rate, non-audio EvenHub sensor stream active so iOS continues receiving BLE
|
|
15
15
|
/// notifications while the Mentra App is backgrounded. The samples stay internal unless IMU was
|
|
@@ -2565,7 +2565,7 @@ class G2: NSObject, SGCManager {
|
|
|
2565
2565
|
/// (aspect-fit, centered on black) — the front half of `convertToG2Bmp`,
|
|
2566
2566
|
/// exposed for the tiler which encodes per-tile BMPs from one render.
|
|
2567
2567
|
private func renderG2Grayscale(_ data: Data, targetWidth: Int, targetHeight: Int) -> Data? {
|
|
2568
|
-
guard let image =
|
|
2568
|
+
guard let image = SdkImage(data: data), let cgImage = image.cgImage else { return nil }
|
|
2569
2569
|
let scale = min(
|
|
2570
2570
|
Double(targetWidth) / Double(cgImage.width), Double(targetHeight) / Double(cgImage.height)
|
|
2571
2571
|
)
|
|
@@ -3101,7 +3101,7 @@ class G2: NSObject, SGCManager {
|
|
|
3101
3101
|
/// Scale source image to fit within containerWidth x containerHeight (maintaining aspect ratio),
|
|
3102
3102
|
/// centered on a black background. Output BMP always matches container dimensions exactly.
|
|
3103
3103
|
private func convertToG2Bmp(_ data: Data, containerWidth: Int, containerHeight: Int) -> Data? {
|
|
3104
|
-
guard let image =
|
|
3104
|
+
guard let image = SdkImage(data: data), let cgImage = image.cgImage else {
|
|
3105
3105
|
Bridge.log("G2: convertToG2Bmp - could not decode image")
|
|
3106
3106
|
return nil
|
|
3107
3107
|
}
|
|
@@ -16,7 +16,6 @@ import Combine
|
|
|
16
16
|
import CoreBluetooth
|
|
17
17
|
import Foundation
|
|
18
18
|
import ImageIO
|
|
19
|
-
import UIKit
|
|
20
19
|
|
|
21
20
|
// MARK: - Supporting Types
|
|
22
21
|
|
|
@@ -355,11 +354,11 @@ class BlePhotoUploadService {
|
|
|
355
354
|
}
|
|
356
355
|
|
|
357
356
|
/**
|
|
358
|
-
* Decode image data (AVIF or JPEG)
|
|
357
|
+
* Decode image data (AVIF or JPEG).
|
|
359
358
|
* AVIF arriving from glasses has a TIFF EXIF block appended to {@code mdat}; iOS ImageIO
|
|
360
359
|
* rejects those bytes the same way Android does. Strip the Exif tail before decoding.
|
|
361
360
|
*/
|
|
362
|
-
private static func decodeImage(imageData: Data) ->
|
|
361
|
+
private static func decodeImage(imageData: Data) -> SdkImage? {
|
|
363
362
|
let isAvif = isAvifData(imageData)
|
|
364
363
|
var decodeData = imageData
|
|
365
364
|
if isAvif && containsExifMarker(in: imageData) {
|
|
@@ -372,13 +371,13 @@ class BlePhotoUploadService {
|
|
|
372
371
|
}
|
|
373
372
|
}
|
|
374
373
|
|
|
375
|
-
if let image =
|
|
374
|
+
if let image = SdkImage(data: decodeData) {
|
|
376
375
|
return image
|
|
377
376
|
}
|
|
378
377
|
|
|
379
378
|
if isAvif {
|
|
380
|
-
if #available(iOS 16.0, *) {
|
|
381
|
-
return
|
|
379
|
+
if #available(iOS 16.0, macOS 13.0, *) {
|
|
380
|
+
return SdkImage(data: decodeData)
|
|
382
381
|
} else {
|
|
383
382
|
Bridge.log("\(TAG): AVIF decoding not supported on iOS < 16")
|
|
384
383
|
return nil
|
|
@@ -1022,6 +1021,7 @@ extension MentraLive: CBCentralManagerDelegate {
|
|
|
1022
1021
|
}
|
|
1023
1022
|
}
|
|
1024
1023
|
|
|
1024
|
+
#if !os(macOS)
|
|
1025
1025
|
nonisolated func centralManager(
|
|
1026
1026
|
_: CBCentralManager, didUpdateANCSAuthorizationFor peripheral: CBPeripheral
|
|
1027
1027
|
) {
|
|
@@ -1033,6 +1033,7 @@ extension MentraLive: CBCentralManagerDelegate {
|
|
|
1033
1033
|
self.enableAncsRelayIfAuthorized()
|
|
1034
1034
|
}
|
|
1035
1035
|
}
|
|
1036
|
+
#endif
|
|
1036
1037
|
|
|
1037
1038
|
nonisolated func centralManager(
|
|
1038
1039
|
_: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?
|
|
@@ -2443,6 +2444,9 @@ class MentraLive: NSObject, SGCManager {
|
|
|
2443
2444
|
// Set connection timeout
|
|
2444
2445
|
startConnectionTimeout()
|
|
2445
2446
|
|
|
2447
|
+
#if os(macOS)
|
|
2448
|
+
centralManager?.connect(peripheral, options: nil)
|
|
2449
|
+
#else
|
|
2446
2450
|
// ANCS is hosted by iOS and is only exposed to authorized accessories.
|
|
2447
2451
|
// Requiring it at connect time lets the system complete that authorization
|
|
2448
2452
|
// flow before the glasses subscribe to the ANCS characteristics.
|
|
@@ -2450,12 +2454,14 @@ class MentraLive: NSObject, SGCManager {
|
|
|
2450
2454
|
peripheral,
|
|
2451
2455
|
options: [CBConnectPeripheralOptionRequiresANCS: true]
|
|
2452
2456
|
)
|
|
2457
|
+
#endif
|
|
2453
2458
|
}
|
|
2454
2459
|
|
|
2455
2460
|
/// Opt the firmware into ANCS only after iOS has authorized this accessory.
|
|
2456
2461
|
/// Old firmware safely ignores the command, while new firmware stays inert
|
|
2457
2462
|
/// for old mobile clients that never send it.
|
|
2458
2463
|
private func enableAncsRelayIfAuthorized() {
|
|
2464
|
+
#if !os(macOS)
|
|
2459
2465
|
guard !ancsRelayEnableRequested,
|
|
2460
2466
|
let peripheral = connectedPeripheral,
|
|
2461
2467
|
txCharacteristic != nil,
|
|
@@ -2473,6 +2479,7 @@ class MentraLive: NSObject, SGCManager {
|
|
|
2473
2479
|
ancsRelayEnableRequested = true
|
|
2474
2480
|
Bridge.log("LIVE: Requested ANCS relay from compatible firmware")
|
|
2475
2481
|
}
|
|
2482
|
+
#endif
|
|
2476
2483
|
}
|
|
2477
2484
|
|
|
2478
2485
|
private func handleReconnection() {
|
|
@@ -10,7 +10,7 @@ import AVFoundation
|
|
|
10
10
|
import Compression
|
|
11
11
|
import CoreBluetooth
|
|
12
12
|
import Foundation
|
|
13
|
-
import
|
|
13
|
+
import CoreGraphics
|
|
14
14
|
|
|
15
15
|
// MARK: - Nimo BLE Constants
|
|
16
16
|
|
|
@@ -846,7 +846,7 @@ class Nimo: NSObject, SGCManager {
|
|
|
846
846
|
let targetHeight = NimoProtocol.NAV_LARGE_MAP_HEIGHT
|
|
847
847
|
Bridge.log("NIMO: displayBitmap → nav large map (navAppEntered=\(navAppEntered))")
|
|
848
848
|
guard let imageData = Data(base64Encoded: base64ImageData),
|
|
849
|
-
let image =
|
|
849
|
+
let image = SdkImage(data: imageData)
|
|
850
850
|
else {
|
|
851
851
|
Bridge.log("NIMO: displayBitmap — could not decode image")
|
|
852
852
|
return false
|
|
@@ -1640,7 +1640,7 @@ class Nimo: NSObject, SGCManager {
|
|
|
1640
1640
|
// MARK: - Bitmap Helpers
|
|
1641
1641
|
|
|
1642
1642
|
/// Aspect-fit the image into `width`x`height` on black, then convert to L8 grayscale.
|
|
1643
|
-
private func bitmapToGrayscale(_ image:
|
|
1643
|
+
private func bitmapToGrayscale(_ image: SdkImage, width: Int, height: Int) -> Data? {
|
|
1644
1644
|
guard let cgImage = image.cgImage else { return nil }
|
|
1645
1645
|
let colorSpace = CGColorSpaceCreateDeviceGray()
|
|
1646
1646
|
guard let context = CGContext(
|
|
@@ -102,7 +102,7 @@ private func jlog(_ message: String) {
|
|
|
102
102
|
ctx.name = "MentraJS: \(id)"
|
|
103
103
|
// Inspectable in dev builds only. iOS 16.4+ guarded.
|
|
104
104
|
#if DEBUG
|
|
105
|
-
if #available(iOS 16.4, *) {
|
|
105
|
+
if #available(iOS 16.4, macOS 13.3, *) {
|
|
106
106
|
ctx.isInspectable = true
|
|
107
107
|
}
|
|
108
108
|
#endif
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
#if os(macOS)
|
|
2
|
+
import Foundation
|
|
3
|
+
|
|
4
|
+
enum AudioSessionMonitor {
|
|
5
|
+
static func isAudioDeviceConnected(devicePattern: String) -> Bool {
|
|
6
|
+
MacAudioDevice.available.contains {
|
|
7
|
+
$0.isBluetooth && $0.name.localizedCaseInsensitiveContains(devicePattern)
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
static func isOtherAudioDeviceConnected(devicePattern: String) -> Bool {
|
|
12
|
+
let output = MacAudioDevice.defaultOutput
|
|
13
|
+
return MacAudioDevice.available.contains {
|
|
14
|
+
$0.id == output && $0.isBluetooth && !$0.name.localizedCaseInsensitiveContains(devicePattern)
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
#endif
|
|
@@ -50,9 +50,15 @@ class PhoneAudioMonitor {
|
|
|
50
50
|
|
|
51
51
|
/// Check if any audio is currently playing on the device (including our own app)
|
|
52
52
|
func isPlaying() -> Bool {
|
|
53
|
+
#if os(macOS)
|
|
54
|
+
// macOS has no AVAudioSession audio-focus contract. Explicit SDK playback
|
|
55
|
+
// is tracked; other applications retain independent system audio routes.
|
|
56
|
+
return ownAppAudioPlaying
|
|
57
|
+
#else
|
|
53
58
|
let session = AVAudioSession.sharedInstance()
|
|
54
59
|
// Combine: other apps playing OR our own app playing
|
|
55
60
|
return session.isOtherAudioPlaying || ownAppAudioPlaying
|
|
61
|
+
#endif
|
|
56
62
|
}
|
|
57
63
|
|
|
58
64
|
func isOwnAppAudioPlaying() -> Bool {
|
|
@@ -88,6 +94,7 @@ class PhoneAudioMonitor {
|
|
|
88
94
|
"PhoneAudioMonitor: Starting audio playback monitoring (initial state: \(lastKnownState ? "playing" : "not playing"))"
|
|
89
95
|
)
|
|
90
96
|
|
|
97
|
+
#if !os(macOS)
|
|
91
98
|
// Register for silenceSecondaryAudioHint notification
|
|
92
99
|
// This is fired when other apps start/stop playing audio
|
|
93
100
|
NotificationCenter.default.addObserver(
|
|
@@ -108,6 +115,7 @@ class PhoneAudioMonitor {
|
|
|
108
115
|
// Start polling as a fallback mechanism
|
|
109
116
|
// Some audio sources might not trigger notifications reliably
|
|
110
117
|
startPolling()
|
|
118
|
+
#endif
|
|
111
119
|
}
|
|
112
120
|
|
|
113
121
|
/// Stop monitoring for phone audio playback changes
|
|
@@ -119,6 +127,7 @@ class PhoneAudioMonitor {
|
|
|
119
127
|
|
|
120
128
|
Bridge.log("PhoneAudioMonitor: Stopping audio playback monitoring")
|
|
121
129
|
|
|
130
|
+
#if !os(macOS)
|
|
122
131
|
NotificationCenter.default.removeObserver(
|
|
123
132
|
self,
|
|
124
133
|
name: AVAudioSession.silenceSecondaryAudioHintNotification,
|
|
@@ -132,11 +141,13 @@ class PhoneAudioMonitor {
|
|
|
132
141
|
)
|
|
133
142
|
|
|
134
143
|
stopPolling()
|
|
144
|
+
#endif
|
|
135
145
|
|
|
136
146
|
listener = nil
|
|
137
147
|
isMonitoring = false
|
|
138
148
|
}
|
|
139
149
|
|
|
150
|
+
#if !os(macOS)
|
|
140
151
|
/// Handle silenceSecondaryAudioHint notification
|
|
141
152
|
@objc private func handleSilenceSecondaryAudioHint(_ notification: Notification) {
|
|
142
153
|
guard let userInfo = notification.userInfo,
|
|
@@ -191,6 +202,8 @@ class PhoneAudioMonitor {
|
|
|
191
202
|
}
|
|
192
203
|
}
|
|
193
204
|
|
|
205
|
+
#endif
|
|
206
|
+
|
|
194
207
|
/// Start polling isOtherAudioPlaying as a fallback mechanism
|
|
195
208
|
private func startPolling() {
|
|
196
209
|
pollingTimer = Timer.scheduledTimer(
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import ImageIO
|
|
3
|
+
import UniformTypeIdentifiers
|
|
4
|
+
|
|
5
|
+
/// Decode upright pixels once, so bitmap rendering and JPEG encoding agree on both Apple platforms.
|
|
6
|
+
struct SdkImage {
|
|
7
|
+
let cgImage: CGImage?
|
|
8
|
+
var size: CGSize {
|
|
9
|
+
CGSize(width: cgImage?.width ?? 0, height: cgImage?.height ?? 0)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
init?(data: Data) {
|
|
13
|
+
guard let source = CGImageSourceCreateWithData(data as CFData, nil),
|
|
14
|
+
let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any],
|
|
15
|
+
let width = properties[kCGImagePropertyPixelWidth] as? Int,
|
|
16
|
+
let height = properties[kCGImagePropertyPixelHeight] as? Int,
|
|
17
|
+
let image = CGImageSourceCreateThumbnailAtIndex(source, 0, [
|
|
18
|
+
kCGImageSourceCreateThumbnailFromImageAlways: true,
|
|
19
|
+
kCGImageSourceCreateThumbnailWithTransform: true,
|
|
20
|
+
kCGImageSourceThumbnailMaxPixelSize: max(width, height),
|
|
21
|
+
] as CFDictionary)
|
|
22
|
+
else { return nil }
|
|
23
|
+
cgImage = image
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
func jpegData(compressionQuality: CGFloat) -> Data? {
|
|
27
|
+
guard let cgImage else { return nil }
|
|
28
|
+
let data = NSMutableData()
|
|
29
|
+
guard let destination = CGImageDestinationCreateWithData(data, UTType.jpeg.identifier as CFString, 1, nil)
|
|
30
|
+
else { return nil }
|
|
31
|
+
CGImageDestinationAddImage(destination, cgImage, [
|
|
32
|
+
kCGImageDestinationLossyCompressionQuality: compressionQuality,
|
|
33
|
+
kCGImagePropertyOrientation: 1,
|
|
34
|
+
] as CFDictionary)
|
|
35
|
+
return CGImageDestinationFinalize(destination) ? data as Data : nil
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
#if os(macOS)
|
|
2
|
+
import CoreAudio
|
|
3
|
+
@testable import MentraBluetoothSDK
|
|
4
|
+
import XCTest
|
|
5
|
+
|
|
6
|
+
final class MacAudioDevicesTests: XCTestCase {
|
|
7
|
+
private let devices = [
|
|
8
|
+
MacAudioDevice(id: 1, name: "Internal", transport: kAudioDeviceTransportTypeBuiltIn, hasInput: true),
|
|
9
|
+
MacAudioDevice(id: 2, name: "Headset A", transport: kAudioDeviceTransportTypeBluetooth, hasInput: true),
|
|
10
|
+
MacAudioDevice(id: 3, name: "Headset B", transport: kAudioDeviceTransportTypeBluetoothLE, hasInput: true),
|
|
11
|
+
MacAudioDevice(id: 4, name: "Speakers", transport: kAudioDeviceTransportTypeBluetooth, hasInput: false),
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
func testBluetoothUsesSelectedInputRegardlessOfEnumerationOrder() {
|
|
15
|
+
for mode in [MicTypes.BLUETOOTH, MicTypes.BLUETOOTH_CLASSIC] {
|
|
16
|
+
for inputs in [devices, devices.reversed()] {
|
|
17
|
+
XCTAssertEqual(MacAudioDevice.input(for: mode, devices: inputs, defaultInput: 3), 3)
|
|
18
|
+
XCTAssertEqual(MacAudioDevice.input(for: mode, devices: inputs, defaultInput: 2), 2)
|
|
19
|
+
XCTAssertNil(MacAudioDevice.input(for: mode, devices: inputs, defaultInput: 1))
|
|
20
|
+
XCTAssertNil(MacAudioDevice.input(for: mode, devices: inputs, defaultInput: 4))
|
|
21
|
+
XCTAssertNil(MacAudioDevice.input(for: mode, devices: inputs, defaultInput: nil))
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
func testInternalModeNeverFallsBackToBluetooth() {
|
|
27
|
+
XCTAssertEqual(MacAudioDevice.input(for: MicTypes.PHONE_INTERNAL, devices: devices, defaultInput: 3), 1)
|
|
28
|
+
XCTAssertNil(MacAudioDevice.input(for: MicTypes.PHONE_INTERNAL, devices: Array(devices.dropFirst()), defaultInput: 3))
|
|
29
|
+
XCTAssertNil(MacAudioDevice.input(for: "invalid", devices: devices, defaultInput: 1))
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
#endif
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import CoreGraphics
|
|
2
|
+
import ImageIO
|
|
3
|
+
@testable import MentraBluetoothSDK
|
|
4
|
+
import UniformTypeIdentifiers
|
|
5
|
+
import XCTest
|
|
6
|
+
|
|
7
|
+
final class SdkImageTests: XCTestCase {
|
|
8
|
+
func testInvalidImageIsRejected() {
|
|
9
|
+
XCTAssertNil(SdkImage(data: Data("not an image".utf8)))
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
func testAllExifOrientationsProduceUprightPixelsAndJpegs() throws {
|
|
13
|
+
// Six asymmetric tiles distinguish rotations from reflections.
|
|
14
|
+
let pixels: [UInt8] = [10, 50, 90, 130, 170, 210]
|
|
15
|
+
let raster = (0 ..< 32).flatMap { y in (0 ..< 48).map { x in pixels[(y / 16) * 3 + x / 16] } }
|
|
16
|
+
let provider = try XCTUnwrap(CGDataProvider(data: Data(raster) as CFData))
|
|
17
|
+
let image = try XCTUnwrap(CGImage(width: 48, height: 32, bitsPerComponent: 8, bitsPerPixel: 8,
|
|
18
|
+
bytesPerRow: 48, space: CGColorSpaceCreateDeviceGray(), bitmapInfo: [],
|
|
19
|
+
provider: provider, decode: nil, shouldInterpolate: false, intent: .defaultIntent))
|
|
20
|
+
let expected: [[UInt8]] = [
|
|
21
|
+
[10, 50, 90, 130, 170, 210], [90, 50, 10, 210, 170, 130],
|
|
22
|
+
[210, 170, 130, 90, 50, 10], [130, 170, 210, 10, 50, 90],
|
|
23
|
+
[10, 130, 50, 170, 90, 210], [130, 10, 170, 50, 210, 90],
|
|
24
|
+
[210, 90, 170, 50, 130, 10], [90, 210, 50, 170, 10, 130],
|
|
25
|
+
]
|
|
26
|
+
for orientation in 1 ... 8 {
|
|
27
|
+
let data = NSMutableData()
|
|
28
|
+
let destination = try XCTUnwrap(CGImageDestinationCreateWithData(data, UTType.tiff.identifier as CFString, 1, nil))
|
|
29
|
+
CGImageDestinationAddImage(destination, image, [kCGImagePropertyOrientation: orientation] as CFDictionary)
|
|
30
|
+
XCTAssertTrue(CGImageDestinationFinalize(destination))
|
|
31
|
+
|
|
32
|
+
let decoded = try XCTUnwrap(SdkImage(data: data as Data))
|
|
33
|
+
let width = orientation < 5 ? 48 : 32
|
|
34
|
+
let height = orientation < 5 ? 32 : 48
|
|
35
|
+
XCTAssertEqual(decoded.size, CGSize(width: width, height: height))
|
|
36
|
+
let context = try XCTUnwrap(CGContext(data: nil, width: width, height: height, bitsPerComponent: 8,
|
|
37
|
+
bytesPerRow: width, space: CGColorSpaceCreateDeviceGray(), bitmapInfo: 0))
|
|
38
|
+
try context.draw(XCTUnwrap(decoded.cgImage), in: CGRect(x: 0, y: 0, width: width, height: height))
|
|
39
|
+
let bytes = try XCTUnwrap(context.data).assumingMemoryBound(to: UInt8.self)
|
|
40
|
+
let actual = stride(from: 8, to: height, by: 16).flatMap { y in
|
|
41
|
+
stride(from: 8, to: width, by: 16).map { x in bytes[y * context.bytesPerRow + x] }
|
|
42
|
+
}
|
|
43
|
+
XCTAssertEqual(actual, expected[orientation - 1], "EXIF \(orientation)")
|
|
44
|
+
|
|
45
|
+
let jpeg = try XCTUnwrap(decoded.jpegData(compressionQuality: 1))
|
|
46
|
+
let source = try XCTUnwrap(CGImageSourceCreateWithData(jpeg as CFData, nil))
|
|
47
|
+
let properties = try XCTUnwrap(CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any])
|
|
48
|
+
XCTAssertEqual(CGImageSourceGetType(source), UTType.jpeg.identifier as CFString)
|
|
49
|
+
XCTAssertEqual(properties[kCGImagePropertyOrientation] as? Int ?? 1, 1)
|
|
50
|
+
XCTAssertEqual(try XCTUnwrap(SdkImage(data: jpeg)).size, decoded.size)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
package/package.json
CHANGED
|
@@ -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.0",
|
|
15
|
-
"releaseIdentity": "3.2.0-dev.
|
|
16
|
-
"releaseSetId": "mentra-3.2.0-dev.
|
|
17
|
-
"sourceCommit": "
|
|
18
|
-
"otaManifestUrl": "https://github.com/Mentra-Community/MentraOS/releases/download/mentra-builds-v3.2.0/mentra-live-ota-3.2.0-dev.
|
|
19
|
-
"otaManifestSha256": "
|
|
15
|
+
"releaseIdentity": "3.2.0-dev.127",
|
|
16
|
+
"releaseSetId": "mentra-3.2.0-dev.127",
|
|
17
|
+
"sourceCommit": "6f63b186e38f659faed60cbe5b9c95f457a32383",
|
|
18
|
+
"otaManifestUrl": "https://github.com/Mentra-Community/MentraOS/releases/download/mentra-builds-v3.2.0/mentra-live-ota-3.2.0-dev.127.json",
|
|
19
|
+
"otaManifestSha256": "426f58f142ca6d710089683c6e9462b0ce0c30d491b95747e960371e082f459c"
|
|
20
20
|
})
|