@mentra/bluetooth-sdk 3.2.0-dev.217 → 3.2.0-dev.222

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/README.md +9 -0
  2. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +23 -2
  3. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +16 -38
  4. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +120 -2
  5. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
  6. package/android/src/main/java/com/mentra/bluetoothsdk/LogForwardingBudget.kt +50 -0
  7. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +12 -0
  8. package/android/src/main/java/com/mentra/bluetoothsdk/audio/AudioModels.kt +11 -0
  9. package/android/src/main/java/com/mentra/bluetoothsdk/streaming/StreamModels.kt +38 -0
  10. package/android/src/main/java/com/mentra/bluetoothsdk/utils/MicSourcePin.kt +31 -0
  11. package/android/src/main/java/com/mentra/bluetoothsdk/utils/audio/PcmStreamPlayer.kt +39 -5
  12. package/android/src/test/java/com/mentra/bluetoothsdk/LogForwardingBudgetTest.kt +49 -0
  13. package/android/src/test/java/com/mentra/bluetoothsdk/MicPcmEventSourceTest.kt +75 -0
  14. package/android/src/test/java/com/mentra/bluetoothsdk/StreamRequestCaptureAudioTest.kt +44 -0
  15. package/android/src/test/java/com/mentra/bluetoothsdk/utils/MicSourcePinTest.kt +68 -0
  16. package/android/src/test/java/com/mentra/bluetoothsdk/utils/NativeLogTest.kt +15 -0
  17. package/build/BluetoothSdk.types.d.ts +27 -0
  18. package/build/BluetoothSdk.types.d.ts.map +1 -1
  19. package/build/BluetoothSdk.types.js.map +1 -1
  20. package/build/_private/BluetoothSdkModule.d.ts +18 -2
  21. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  22. package/build/_private/BluetoothSdkModule.js.map +1 -1
  23. package/build/generated/releaseMetadata.js +5 -5
  24. package/build/generated/releaseMetadata.js.map +1 -1
  25. package/ios/BluetoothSdkModule.swift +15 -1
  26. package/ios/Source/BluetoothSdkDefaults.swift +1 -1
  27. package/ios/Source/Bridge.swift +13 -45
  28. package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
  29. package/ios/Source/streaming/StreamModels.swift +48 -2
  30. package/package.json +1 -1
  31. package/src/BluetoothSdk.types.ts +28 -0
  32. package/src/_private/BluetoothSdkModule.ts +24 -2
  33. package/src/generated/releaseMetadata.ts +5 -5
@@ -640,8 +640,11 @@ public class BluetoothSdkModule: Module, MentraBluetoothSDKDelegate {
640
640
 
641
641
  // MARK: - Live PCM output stream (miniapp speaker.createStream)
642
642
 
643
+ // jitterMs is accepted for signature parity with Android and ignored: AVAudioEngine's
644
+ // scheduled-buffer playout has no equivalent fixed track buffer to size, so there is no
645
+ // knob here that would mean the same thing.
643
646
  AsyncFunction("pcmStreamOpen") {
644
- (streamId: String, sampleRate: Int, channels: Int, volume: Double) async throws in
647
+ (streamId: String, sampleRate: Int, channels: Int, volume: Double, _: Int?) async throws in
645
648
  try PcmStreamManager.open(
646
649
  streamId: streamId,
647
650
  sampleRate: sampleRate,
@@ -711,6 +714,17 @@ public class BluetoothSdkModule: Module, MentraBluetoothSDKDelegate {
711
714
  }
712
715
  }
713
716
 
717
+ /**
718
+ * Android-only today. Present so a host that pins the microphone for a call does not have
719
+ * to branch on platform, and so a caller cannot mistake a missing function for a pin that
720
+ * was taken: the ACS capability gate excludes iOS precisely because this cannot honour it.
721
+ */
722
+ AsyncFunction("setMicSourcePin") { (source: String?) in
723
+ if source != nil {
724
+ Bridge.log("BluetoothSdkModule: setMicSourcePin(\(source ?? "")) is not implemented on iOS; ignoring")
725
+ }
726
+ }
727
+
714
728
  AsyncFunction("restartTranscriber") {
715
729
  await MainActor.run {
716
730
  DeviceManager.shared.restartTranscriber()
@@ -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.0-dev.217"
14
+ private static let swiftPackageSdkVersion = "3.2.0-dev.222"
15
15
  private static let swiftPackageSdkVersionPlaceholder = "__MENTRA" + "_BLUETOOTH_SDK_VERSION__"
16
16
 
17
17
  private static func normalizedSdkVersion(_ value: String?) -> String? {
@@ -15,17 +15,6 @@ class Bridge {
15
15
  private static let micChannels = 1
16
16
  private static let lc3FrameDurationMs = 10
17
17
  private static let defaultLc3FrameSizeBytes = 60
18
- private static let audioTraceMetadataKeys = [
19
- "sampleRate",
20
- "bitsPerSample",
21
- "channels",
22
- "encoding",
23
- "frameDurationMs",
24
- "frameSizeBytes",
25
- "bitrate",
26
- "packetizedFromGlasses",
27
- "voiceActivityDetectionEnabled",
28
- ]
29
18
  private static let eventSinkLock = NSLock()
30
19
  private static let defaultEventSinkId = "default"
31
20
  private static var eventSinks: [String: (String, [String: Any]) -> Void] = [:]
@@ -148,6 +137,10 @@ class Bridge {
148
137
  "channels": micChannels,
149
138
  "encoding": "pcm_s16le",
150
139
  "voiceActivityDetectionEnabled": voiceActivityDetectionEnabled,
140
+ // Stamped per frame so a consumer that asked for a specific microphone can verify it
141
+ // rather than assume it. Read now, not captured: the point is which microphone is
142
+ // selected for this buffer. Empty when the SDK has not selected one.
143
+ "source": DeviceStore.shared.get("bluetooth", "currentMic") as? String ?? "",
151
144
  ]
152
145
  }
153
146
 
@@ -700,47 +693,22 @@ class Bridge {
700
693
  dispatchEvent(type, body)
701
694
  }
702
695
 
696
+ /// Returns nil for events that must not be traced.
697
+ ///
698
+ /// "log" is excluded so tracing never recurses back through the log event. Audio payload
699
+ /// events are excluded because they arrive at frame rate, which made every microphone
700
+ /// frame emit a second bridge event. On Android that overflowed the JNI global reference
701
+ /// table and aborted the process; here it is wasted work on the audio path. Audio faults
702
+ /// (sequence gaps, decode failures) are still reported through "mic_health", and healthy
703
+ /// frames need no trace.
703
704
  private static func tracePayloadForTypedMessage(_ type: String, body: [String: Any]) -> [String: Any]? {
704
- if type == "log" {
705
+ if type == "log" || isAudioPayloadEvent(type) {
705
706
  return nil
706
707
  }
707
- if isAudioPayloadEvent(type) {
708
- return audioTracePayload(type, body: body)
709
- }
710
708
  return body
711
709
  }
712
710
 
713
711
  private static func isAudioPayloadEvent(_ type: String) -> Bool {
714
712
  type == "mic_pcm" || type == "mic_lc3"
715
713
  }
716
-
717
- private static func audioTracePayload(_ type: String, body: [String: Any]) -> [String: Any] {
718
- var payload: [String: Any] = [
719
- "type": type,
720
- "timestamp": Int(Date().timeIntervalSince1970 * 1000),
721
- "payloadOmitted": true,
722
- "payloadOmittedReason": "audio",
723
- ]
724
-
725
- switch type {
726
- case "mic_pcm":
727
- if let data = body["pcm"] as? Data {
728
- payload["audioBytes"] = data.count
729
- }
730
- case "mic_lc3":
731
- if let data = body["lc3"] as? Data {
732
- payload["audioBytes"] = data.count
733
- }
734
- default:
735
- break
736
- }
737
-
738
- for key in audioTraceMetadataKeys {
739
- if let value = body[key] {
740
- payload[key] = value
741
- }
742
- }
743
-
744
- return payload
745
- }
746
714
  }
@@ -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.217"
7
- static let releaseSetId = "mentra-3.2.0-dev.217"
8
- static let sourceCommit = "acd4336bcf67c5e2c7ddfa17ea1a2600dc7fb1d9"
9
- static let otaManifestUrl = "https://github.com/Mentra-Community/MentraOS/releases/download/mentra-builds-v3.2.0/mentra-live-ota-3.2.0-dev.217.json"
10
- static let otaManifestSha256 = "4ec6bf57ab08ef2749a58232737dface3b0ddb0d8269b61e134b7cb5986efa4a"
6
+ static let releaseIdentity = "3.2.0-dev.222"
7
+ static let releaseSetId = "mentra-3.2.0-dev.222"
8
+ static let sourceCommit = "7865d7f192a2d9a732269857bb877a3a8a6bf4b8"
9
+ static let otaManifestUrl = "https://github.com/Mentra-Community/MentraOS/releases/download/mentra-builds-v3.2.0/mentra-live-ota-3.2.0-dev.222.json"
10
+ static let otaManifestSha256 = "8c33e8beb5ba61eb0821d89b58d81fe2bbf05641d0a99b2e1972926863cc6ab7"
11
11
  }
@@ -87,6 +87,34 @@ public struct StreamAudioConfig {
87
87
  }
88
88
  }
89
89
 
90
+ /// ICE overrides for a WHIP stream. Ignored by the RTMP and SRT paths.
91
+ public struct StreamIceConfig {
92
+ /// STUN server the glasses use while gathering candidates.
93
+ ///
94
+ /// `nil` and empty are different answers, and both have to survive the BLE round trip. `nil`
95
+ /// leaves the glasses on their default Cloudflare STUN server; an empty string is an explicit
96
+ /// request for host-only gathering, which is what a WHIP server on the glasses' own hotspot
97
+ /// needs, since a reflexive candidate there is meaningless and unreachable.
98
+ public let stun: String?
99
+
100
+ public init(stun: String? = nil) {
101
+ self.stun = stun
102
+ }
103
+
104
+ var dictionary: [String: Any] {
105
+ var values: [String: Any] = [:]
106
+ // Not filtered on emptiness, unlike the other optional string fields here: "" is the
107
+ // host-only signal, so dropping it would silently restore the default STUN server.
108
+ if let stun { values["stun"] = stun }
109
+ return values
110
+ }
111
+
112
+ init?(values: [String: Any]?) {
113
+ guard let values else { return nil }
114
+ self.init(stun: values["stun"] as? String ?? values["s"] as? String)
115
+ }
116
+ }
117
+
90
118
  /// Effective video settings reported by the glasses after defaults and clamps.
91
119
  public struct StreamResolvedVideoConfig: Equatable {
92
120
  /// Encoded output width sent to the stream endpoint.
@@ -286,6 +314,10 @@ public struct StreamRequest {
286
314
  public let audio: StreamAudioConfig?
287
315
  public let authToken: String?
288
316
  public let captureAudio: Bool
317
+ public let ice: StreamIceConfig?
318
+ /// Correlation id the glasses echo in every SOFTAP_TRACE line, so phone and glasses logs can
319
+ /// be joined despite unsynchronised clocks.
320
+ public let traceId: String?
289
321
 
290
322
  public init(
291
323
  streamUrl: String,
@@ -294,7 +326,9 @@ public struct StreamRequest {
294
326
  video: StreamVideoConfig? = nil,
295
327
  audio: StreamAudioConfig? = nil,
296
328
  authToken: String? = nil,
297
- captureAudio: Bool = true
329
+ captureAudio: Bool = true,
330
+ ice: StreamIceConfig? = nil,
331
+ traceId: String? = nil
298
332
  ) {
299
333
  self.streamUrl = streamUrl
300
334
  self.streamId = streamId
@@ -303,6 +337,8 @@ public struct StreamRequest {
303
337
  self.audio = audio
304
338
  self.authToken = authToken
305
339
  self.captureAudio = captureAudio
340
+ self.ice = ice
341
+ self.traceId = traceId
306
342
  }
307
343
 
308
344
  init(values: [String: Any]) {
@@ -317,7 +353,11 @@ public struct StreamRequest {
317
353
  video: StreamVideoConfig(values: values["video"] as? [String: Any]),
318
354
  audio: StreamAudioConfig(values: values["audio"] as? [String: Any]),
319
355
  authToken: values["authToken"] as? String ?? values["auth_token"] as? String,
320
- captureAudio: (values["captureAudio"] as? Bool) ?? (values["ca"] as? Bool) ?? true
356
+ captureAudio: (values["captureAudio"] as? Bool) ?? (values["ca"] as? Bool) ?? true,
357
+ ice: StreamIceConfig(
358
+ values: (values["ice"] as? [String: Any]) ?? (values["i"] as? [String: Any])
359
+ ),
360
+ traceId: values["traceId"] as? String
321
361
  )
322
362
  }
323
363
 
@@ -339,6 +379,12 @@ public struct StreamRequest {
339
379
  if !captureAudio {
340
380
  values["captureAudio"] = false
341
381
  }
382
+ if let iceValues = ice?.dictionary, !iceValues.isEmpty {
383
+ values["ice"] = iceValues
384
+ }
385
+ if let traceId, !traceId.isEmpty {
386
+ values["traceId"] = traceId
387
+ }
342
388
  return values
343
389
  }
344
390
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mentra/bluetooth-sdk",
3
- "version": "3.2.0-dev.217",
3
+ "version": "3.2.0-dev.222",
4
4
  "description": "SDK for communicating with smart glasses",
5
5
  "main": "build/index.js",
6
6
  "react-native": "src/index.ts",
@@ -714,6 +714,20 @@ export type StreamAudioConfig = {
714
714
  noiseSuppression?: boolean
715
715
  }
716
716
 
717
+ /** ICE overrides for a WHIP stream. Ignored by the RTMP and SRT paths. */
718
+ export type StreamIceConfig = {
719
+ /**
720
+ * STUN server the glasses should use while gathering candidates.
721
+ *
722
+ * Omit to keep the default Cloudflare STUN server. Pass an empty string to force host-only
723
+ * gathering, which is what SoftAP calling needs: the WHIP server runs on the phone across the
724
+ * glasses' own hotspot, so a server-reflexive candidate is meaningless and there is no route to
725
+ * a STUN server anyway. A value that is not a `stun:`/`stuns:` URI is ignored and the default is
726
+ * kept, so a malformed override cannot silently disable ICE.
727
+ */
728
+ stun?: string
729
+ }
730
+
717
731
  export type StreamStartRequest = {
718
732
  type?: "start_stream"
719
733
  streamUrl: string
@@ -721,8 +735,16 @@ export type StreamStartRequest = {
721
735
  sound?: boolean
722
736
  video?: StreamVideoConfig
723
737
  audio?: StreamAudioConfig
738
+ ice?: StreamIceConfig
724
739
  /** When false, glasses skip mic capture. Defaults to true. */
725
740
  captureAudio?: boolean
741
+ /**
742
+ * Correlation id echoed by the glasses in every SOFTAP_TRACE log line, so phone and glasses
743
+ * logs can be joined despite having unsynchronised clocks.
744
+ *
745
+ * TEMPORARY: part of the SoftAP diagnostic trace layer.
746
+ */
747
+ traceId?: string
726
748
  }
727
749
 
728
750
  export type StreamKeepAliveRequest = {
@@ -791,6 +813,12 @@ export type MicPcmEvent = {
791
813
  channels: 1
792
814
  encoding: "pcm_s16le"
793
815
  voiceActivityDetectionEnabled: boolean
816
+ /**
817
+ * The microphone this buffer came from (`"glasses"`, `"phone"`, `"bluetooth"`, or `""` when none
818
+ * is selected). Stamped per frame because the SDK can move the source mid-stream, so a consumer
819
+ * that told a remote party which microphone it is sending can check rather than assume.
820
+ */
821
+ source: string
794
822
  }
795
823
 
796
824
  export type MicLc3Event = {
@@ -209,6 +209,15 @@ declare class BluetoothSdkNativeModule extends NativeModule<BluetoothSdkModuleEv
209
209
  // Microphone Commands
210
210
  setMicState(enabled: boolean, useGlassesMic?: boolean, sendTranscript?: boolean, sendLc3Data?: boolean): Promise<void>
211
211
  setPreferredMic(preferredMic: MicPreference): Promise<void>
212
+ /**
213
+ * Lock microphone selection to one source (`"glasses"`) until released with `null`.
214
+ *
215
+ * Stronger than [setPreferredMic]: a preference is a ranking the SDK falls through when a source
216
+ * is unavailable, while a pin has no fallback and reports `mic_health` with
217
+ * `reason: "pinned-source-unavailable"` instead. Android only; the iOS implementation is a no-op,
218
+ * so a caller that depends on the guarantee must gate on platform.
219
+ */
220
+ setMicSourcePin(source: string | null): Promise<void>
212
221
  restartTranscriber(): Promise<void>
213
222
 
214
223
  // Audio Playback Monitoring
@@ -220,8 +229,21 @@ declare class BluetoothSdkNativeModule extends NativeModule<BluetoothSdkModuleEv
220
229
  // ? 16-bit LE PCM chunks into a streaming AudioTrack (USAGE_MEDIA, so it
221
230
  // follows the phone's media route, e.g. A2DP to glasses). Implemented with
222
231
  // AudioTrack on Android and AVAudioEngine on iOS.
223
- /** Open a PCM stream session. One AudioTrack per id; caller manages ids. */
224
- pcmStreamOpen(streamId: string, sampleRate: number, channels: number, volume: number): Promise<void>
232
+ /**
233
+ * Open a PCM stream session. One AudioTrack per id; caller manages ids.
234
+ *
235
+ * `jitterMs` sizes the playout buffer and is therefore also the stream's floor latency, since a
236
+ * streaming track fills to its buffer and stays there. Omit it for one-way media, where the
237
+ * default cushion is inaudible; pass a small value for conversational audio, where the same
238
+ * cushion is delay before the far end is heard. Android only — iOS accepts and ignores it.
239
+ */
240
+ pcmStreamOpen(
241
+ streamId: string,
242
+ sampleRate: number,
243
+ channels: number,
244
+ volume: number,
245
+ jitterMs?: number,
246
+ ): Promise<void>
225
247
  /**
226
248
  * Append base64 PCM. Resolves with the queued-but-unplayed backlog in ms;
227
249
  * blocks (on a background dispatcher) while the backlog is above the
@@ -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.217",
16
- "releaseSetId": "mentra-3.2.0-dev.217",
17
- "sourceCommit": "acd4336bcf67c5e2c7ddfa17ea1a2600dc7fb1d9",
18
- "otaManifestUrl": "https://github.com/Mentra-Community/MentraOS/releases/download/mentra-builds-v3.2.0/mentra-live-ota-3.2.0-dev.217.json",
19
- "otaManifestSha256": "4ec6bf57ab08ef2749a58232737dface3b0ddb0d8269b61e134b7cb5986efa4a"
15
+ "releaseIdentity": "3.2.0-dev.222",
16
+ "releaseSetId": "mentra-3.2.0-dev.222",
17
+ "sourceCommit": "7865d7f192a2d9a732269857bb877a3a8a6bf4b8",
18
+ "otaManifestUrl": "https://github.com/Mentra-Community/MentraOS/releases/download/mentra-builds-v3.2.0/mentra-live-ota-3.2.0-dev.222.json",
19
+ "otaManifestSha256": "8c33e8beb5ba61eb0821d89b58d81fe2bbf05641d0a99b2e1972926863cc6ab7"
20
20
  })