@mentra/bluetooth-sdk 0.1.20 → 0.1.21-beta.0

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 (73) hide show
  1. package/README.md +5 -1
  2. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +127 -24
  3. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +65 -14
  4. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +46 -3
  5. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +1 -1
  6. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +369 -97
  7. package/android/src/main/java/com/mentra/bluetoothsdk/MentraLocalNetworkModule.kt +333 -0
  8. package/android/src/main/java/com/mentra/bluetoothsdk/camera/CameraModels.kt +3 -0
  9. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.kt +1 -1
  10. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +16 -9
  11. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Mach1.kt +1 -1
  12. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +183 -30
  13. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +1 -1
  14. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Nimo.kt +1 -1
  15. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +4 -2
  16. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Simulated.kt +1 -1
  17. package/android/src/main/java/com/mentra/bluetoothsdk/status/WifiHotspotStatus.kt +19 -3
  18. package/android/src/main/java/com/mentra/bluetoothsdk/streaming/StreamModels.kt +15 -2
  19. package/android/src/main/java/com/mentra/bluetoothsdk/tts/TTSTools.kt +115 -47
  20. package/android/src/main/java/com/mentra/bluetoothsdk/utils/audio/PcmStreamPlayer.kt +328 -0
  21. package/android/src/test/java/com/mentra/bluetoothsdk/camera/PhotoRequestTest.kt +15 -0
  22. package/build/BluetoothSdk.types.d.ts +46 -1
  23. package/build/BluetoothSdk.types.d.ts.map +1 -1
  24. package/build/BluetoothSdk.types.js.map +1 -1
  25. package/build/_internal.d.ts +2 -0
  26. package/build/_internal.d.ts.map +1 -1
  27. package/build/_internal.js +2 -0
  28. package/build/_internal.js.map +1 -1
  29. package/build/_private/BluetoothSdkModule.d.ts +23 -2
  30. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  31. package/build/_private/BluetoothSdkModule.js +20 -0
  32. package/build/_private/BluetoothSdkModule.js.map +1 -1
  33. package/build/_private/MentraLocalNetworkModule.d.ts +37 -0
  34. package/build/_private/MentraLocalNetworkModule.d.ts.map +1 -0
  35. package/build/_private/MentraLocalNetworkModule.js +3 -0
  36. package/build/_private/MentraLocalNetworkModule.js.map +1 -0
  37. package/build/_private/cameraRequestPayload.d.ts.map +1 -1
  38. package/build/_private/cameraRequestPayload.js +1 -0
  39. package/build/_private/cameraRequestPayload.js.map +1 -1
  40. package/build/_private/photoRequestPayload.d.ts.map +1 -1
  41. package/build/_private/photoRequestPayload.js +1 -0
  42. package/build/_private/photoRequestPayload.js.map +1 -1
  43. package/build/index.d.ts.map +1 -1
  44. package/build/index.js +4 -0
  45. package/build/index.js.map +1 -1
  46. package/expo-module.config.json +1 -0
  47. package/ios/BluetoothSdkModule.swift +72 -0
  48. package/ios/Source/BluetoothSdkDefaults.swift +1 -1
  49. package/ios/Source/Bridge.swift +72 -18
  50. package/ios/Source/DeviceManager.swift +51 -3
  51. package/ios/Source/DeviceStore.swift +1 -1
  52. package/ios/Source/MentraBluetoothSDK.swift +289 -25
  53. package/ios/Source/audio/PcmStreamPlayer.swift +410 -0
  54. package/ios/Source/camera/CameraModels.swift +8 -3
  55. package/ios/Source/sgcs/G1.swift +1 -1
  56. package/ios/Source/sgcs/G2.swift +19 -7
  57. package/ios/Source/sgcs/Mach1.swift +1 -1
  58. package/ios/Source/sgcs/MentraLive.swift +318 -37
  59. package/ios/Source/sgcs/MentraNex.swift +1 -1
  60. package/ios/Source/sgcs/Nimo.swift +1 -1
  61. package/ios/Source/sgcs/SGCManager.swift +5 -2
  62. package/ios/Source/sgcs/Simulated.swift +1 -1
  63. package/ios/Source/status/WifiHotspotStatus.swift +21 -2
  64. package/ios/Source/streaming/StreamModels.swift +9 -0
  65. package/ios/Source/tts/TTSTools.swift +1 -1
  66. package/package.json +1 -2
  67. package/src/BluetoothSdk.types.ts +49 -0
  68. package/src/_internal.ts +2 -0
  69. package/src/_private/BluetoothSdkModule.ts +64 -13
  70. package/src/_private/MentraLocalNetworkModule.ts +50 -0
  71. package/src/_private/cameraRequestPayload.ts +1 -0
  72. package/src/_private/photoRequestPayload.ts +2 -3
  73. package/src/index.ts +4 -0
@@ -9,6 +9,9 @@ import com.k2fsa.sherpa.onnx.OfflineTtsModelConfig
9
9
  import com.k2fsa.sherpa.onnx.OfflineTtsSupertonicModelConfig
10
10
  import com.mentra.bluetoothsdk.Bridge
11
11
  import java.io.File
12
+ import java.util.concurrent.Executors
13
+ import java.util.concurrent.ScheduledFuture
14
+ import java.util.concurrent.TimeUnit
12
15
 
13
16
  /**
14
17
  * Utilities for offline Sherpa-ONNX Supertonic 3 TTS model management.
@@ -31,6 +34,21 @@ object TTSTools {
31
34
  private const val FILE_UNICODE_INDEXER = "unicode_indexer.bin"
32
35
  private const val FILE_VOICE_STYLE = "voice.bin"
33
36
 
37
+ /**
38
+ * Keep the native model warm briefly after use to reduce repeated TTS latency while
39
+ * still reclaiming its memory when speech has been idle for a while.
40
+ */
41
+ private const val MODEL_IDLE_TIMEOUT_MINUTES = 3L
42
+ private val modelLock = Any()
43
+ private val teardownExecutor =
44
+ Executors.newSingleThreadScheduledExecutor { runnable ->
45
+ Thread(runnable, "MentraTtsIdleTeardown").apply { isDaemon = true }
46
+ }
47
+ private var cachedTts: OfflineTts? = null
48
+ private var cachedModelPath: String? = null
49
+ private var teardownFuture: ScheduledFuture<*>? = null
50
+ private var teardownGeneration = 0L
51
+
34
52
  private val REQUIRED_FILES = listOf(
35
53
  FILE_DURATION_PREDICTOR,
36
54
  FILE_TEXT_ENCODER,
@@ -42,6 +60,11 @@ object TTSTools {
42
60
  )
43
61
 
44
62
  fun setTtsModelDetails(context: Context, path: String, languageCode: String) {
63
+ synchronized(modelLock) {
64
+ if (cachedModelPath != null && cachedModelPath != path) {
65
+ releaseCachedModelLocked("model path changed")
66
+ }
67
+ }
45
68
  val prefs = getPrefs(context)
46
69
  prefs.edit().apply {
47
70
  putString(KEY_TTS_MODEL_PATH, path)
@@ -118,59 +141,104 @@ object TTSTools {
118
141
  return false
119
142
  }
120
143
 
121
- val modelDir = File(modelPath)
122
144
  val outputFile = File(outputPath)
123
145
  outputFile.parentFile?.mkdirs()
124
146
 
125
- var tts: OfflineTts? = null
126
- return try {
127
- val supertonic = OfflineTtsSupertonicModelConfig(
128
- durationPredictor = File(modelDir, FILE_DURATION_PREDICTOR).absolutePath,
129
- textEncoder = File(modelDir, FILE_TEXT_ENCODER).absolutePath,
130
- vectorEstimator = File(modelDir, FILE_VECTOR_ESTIMATOR).absolutePath,
131
- vocoder = File(modelDir, FILE_VOCODER).absolutePath,
132
- ttsJson = File(modelDir, FILE_TTS_JSON).absolutePath,
133
- unicodeIndexer = File(modelDir, FILE_UNICODE_INDEXER).absolutePath,
134
- voiceStyle = File(modelDir, FILE_VOICE_STYLE).absolutePath,
135
- )
136
-
137
- val modelConfig = OfflineTtsModelConfig(
138
- supertonic = supertonic,
139
- numThreads = 2,
140
- provider = "cpu",
141
- )
142
-
143
- val config = OfflineTtsConfig(
144
- model = modelConfig,
145
- maxNumSentences = 1,
146
- silenceScale = 0.2f,
147
- )
148
-
149
- tts = OfflineTts(null, config)
150
-
151
- // sid out of range falls back to F1 (sid 0); clamp 0..9.
152
- val sid = speakerId.coerceIn(0, tts.numSpeakers() - 1)
153
- val clampedSpeed = speed.coerceIn(0.5f, 2.0f)
154
- val lang = languageTagToSupertonicLang(languageTag)
155
- val genConfig = GenerationConfig(
156
- sid = sid,
157
- speed = clampedSpeed,
158
- extra = mapOf("lang" to lang),
159
- )
160
- val audio = tts.generateWithConfig(text = text, config = genConfig)
161
- val saved = audio.save(outputFile.absolutePath)
162
- Bridge.log("TTS generated ${outputFile.absolutePath}: saved=$saved sid=$sid lang=$lang")
163
- saved
164
- } catch (e: Exception) {
165
- Bridge.log("TTS_ERROR: ${e.javaClass.simpleName}: ${e.message}")
166
- e.printStackTrace()
167
- false
168
- } finally {
147
+ return synchronized(modelLock) {
169
148
  try {
170
- tts?.release()
149
+ val tts = getOrCreateModelLocked(modelPath)
150
+
151
+ // sid out of range falls back to F1 (sid 0); clamp 0..9.
152
+ val sid = speakerId.coerceIn(0, tts.numSpeakers() - 1)
153
+ val clampedSpeed = speed.coerceIn(0.5f, 2.0f)
154
+ val lang = languageTagToSupertonicLang(languageTag)
155
+ val genConfig = GenerationConfig(
156
+ sid = sid,
157
+ speed = clampedSpeed,
158
+ numSteps = 5,
159
+ extra = mapOf("lang" to lang),
160
+ )
161
+ val audio = tts.generateWithConfig(text = text, config = genConfig)
162
+ val saved = audio.save(outputFile.absolutePath)
163
+ Bridge.log("TTS generated ${outputFile.absolutePath}: saved=$saved sid=$sid lang=$lang")
164
+ saved
171
165
  } catch (e: Exception) {
172
- Bridge.log("TTS release failed: ${e.message}")
166
+ Bridge.log("TTS_ERROR: ${e.javaClass.simpleName}: ${e.message}")
167
+ e.printStackTrace()
168
+ false
169
+ } finally {
170
+ scheduleIdleTeardownLocked()
171
+ }
172
+ }
173
+ }
174
+
175
+ private fun getOrCreateModelLocked(modelPath: String): OfflineTts {
176
+ cachedTts?.let { cached ->
177
+ if (cachedModelPath == modelPath) {
178
+ Bridge.log("TTS reusing cached model")
179
+ return cached
173
180
  }
181
+ releaseCachedModelLocked("model path changed")
182
+ }
183
+
184
+ val modelDir = File(modelPath)
185
+ val supertonic = OfflineTtsSupertonicModelConfig(
186
+ durationPredictor = File(modelDir, FILE_DURATION_PREDICTOR).absolutePath,
187
+ textEncoder = File(modelDir, FILE_TEXT_ENCODER).absolutePath,
188
+ vectorEstimator = File(modelDir, FILE_VECTOR_ESTIMATOR).absolutePath,
189
+ vocoder = File(modelDir, FILE_VOCODER).absolutePath,
190
+ ttsJson = File(modelDir, FILE_TTS_JSON).absolutePath,
191
+ unicodeIndexer = File(modelDir, FILE_UNICODE_INDEXER).absolutePath,
192
+ voiceStyle = File(modelDir, FILE_VOICE_STYLE).absolutePath,
193
+ )
194
+ val modelConfig = OfflineTtsModelConfig(
195
+ supertonic = supertonic,
196
+ numThreads = 8,
197
+ provider = "cpu",
198
+ )
199
+ val config = OfflineTtsConfig(
200
+ model = modelConfig,
201
+ maxNumSentences = 1,
202
+ silenceScale = 0.2f,
203
+ )
204
+ return OfflineTts(null, config).also {
205
+ cachedTts = it
206
+ cachedModelPath = modelPath
207
+ Bridge.log("TTS loaded model; keeping it warm for $MODEL_IDLE_TIMEOUT_MINUTES minutes after use")
208
+ }
209
+ }
210
+
211
+ private fun scheduleIdleTeardownLocked() {
212
+ if (cachedTts == null) return
213
+ teardownFuture?.cancel(false)
214
+ val generation = ++teardownGeneration
215
+ teardownFuture =
216
+ teardownExecutor.schedule(
217
+ {
218
+ synchronized(modelLock) {
219
+ if (generation == teardownGeneration) {
220
+ releaseCachedModelLocked("idle for $MODEL_IDLE_TIMEOUT_MINUTES minutes")
221
+ }
222
+ }
223
+ },
224
+ MODEL_IDLE_TIMEOUT_MINUTES,
225
+ TimeUnit.MINUTES,
226
+ )
227
+ }
228
+
229
+ private fun releaseCachedModelLocked(reason: String) {
230
+ teardownFuture?.cancel(false)
231
+ teardownFuture = null
232
+ teardownGeneration++
233
+ val tts = cachedTts
234
+ cachedTts = null
235
+ cachedModelPath = null
236
+ if (tts == null) return
237
+ try {
238
+ tts.release()
239
+ Bridge.log("TTS released cached model: $reason")
240
+ } catch (e: Exception) {
241
+ Bridge.log("TTS release failed: ${e.message}")
174
242
  }
175
243
  }
176
244
 
@@ -0,0 +1,328 @@
1
+ package com.mentra.bluetoothsdk.utils.audio
2
+
3
+ import android.media.AudioAttributes
4
+ import android.media.AudioFormat
5
+ import android.media.AudioTrack
6
+ import android.util.Base64
7
+ import android.util.Log
8
+ import java.util.ArrayDeque
9
+ import java.util.concurrent.locks.ReentrantLock
10
+ import kotlin.concurrent.withLock
11
+ import kotlin.math.max
12
+
13
+ /**
14
+ * Live PCM chunk player for miniapp `speaker.createStream()`.
15
+ *
16
+ * One instance = one stream session. 16-bit LE PCM only. Unlike the SCO-tuned
17
+ * [PCMAudioPlayer], this uses USAGE_MEDIA so the OS routes it like any media
18
+ * playback (A2DP to connected glasses, else phone speaker).
19
+ *
20
+ * Threading: a dedicated feeder thread drains an in-memory chunk queue into a
21
+ * MODE_STREAM AudioTrack (AudioTrack.write blocks there, not on callers).
22
+ * `write` blocks the CALLING thread only while the backlog exceeds
23
+ * [BACKPRESSURE_CEILING_MS] — call it from a background dispatcher. When the
24
+ * track underruns (queue empty), AudioTrack plays silence and playback resumes
25
+ * seamlessly on the next chunk; no stop/start pops.
26
+ */
27
+ class PcmStreamPlayer(
28
+ private val streamId: String,
29
+ private val sampleRate: Int,
30
+ channels: Int,
31
+ volume: Float,
32
+ ) {
33
+ companion object {
34
+ private const val TAG = "PcmStreamPlayer"
35
+
36
+ /** write() blocks while more than this is queued but unplayed. */
37
+ private const val BACKPRESSURE_CEILING_MS = 2_000L
38
+
39
+ /** Hard cap — writes that would exceed this are rejected outright. */
40
+ private const val MAX_BACKLOG_MS = 10_000L
41
+
42
+ /** AudioTrack buffer: max(4x minimum, 500ms) of jitter headroom. */
43
+ private const val TRACK_BUFFER_MS = 500
44
+ }
45
+
46
+ private val bytesPerFrame = 2 * channels
47
+ private val channelMask =
48
+ if (channels >= 2) AudioFormat.CHANNEL_OUT_STEREO else AudioFormat.CHANNEL_OUT_MONO
49
+
50
+ private val lock = ReentrantLock()
51
+ private val queueChanged = lock.newCondition()
52
+
53
+ /** Pending chunks not yet handed to AudioTrack. Guarded by [lock]. */
54
+ private val queue = ArrayDeque<ByteArray>()
55
+ private var queuedBytes = 0L
56
+
57
+ /** Total frames handed to AudioTrack.write. Guarded by [lock]. */
58
+ private var framesWritten = 0L
59
+
60
+ private var closing = false
61
+ private var aborted = false
62
+ private var writeInProgress = false
63
+ private var released = false
64
+ private var finalPlayedFrames: Long? = null
65
+ private var endedError: String? = null
66
+
67
+ private val audioTrack: AudioTrack
68
+ private val feeder: Thread
69
+
70
+ init {
71
+ val minBuf =
72
+ AudioTrack.getMinBufferSize(sampleRate, channelMask, AudioFormat.ENCODING_PCM_16BIT)
73
+ val jitterBuf = sampleRate * bytesPerFrame * TRACK_BUFFER_MS / 1000
74
+ audioTrack =
75
+ AudioTrack.Builder()
76
+ .setAudioAttributes(
77
+ AudioAttributes.Builder()
78
+ .setUsage(AudioAttributes.USAGE_MEDIA)
79
+ .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
80
+ .build()
81
+ )
82
+ .setAudioFormat(
83
+ AudioFormat.Builder()
84
+ .setEncoding(AudioFormat.ENCODING_PCM_16BIT)
85
+ .setSampleRate(sampleRate)
86
+ .setChannelMask(channelMask)
87
+ .build()
88
+ )
89
+ .setBufferSizeInBytes(max(minBuf * 4, jitterBuf))
90
+ .setTransferMode(AudioTrack.MODE_STREAM)
91
+ .build()
92
+ if (audioTrack.state != AudioTrack.STATE_INITIALIZED) {
93
+ audioTrack.release()
94
+ throw IllegalStateException("AudioTrack failed to initialize (rate=$sampleRate)")
95
+ }
96
+ audioTrack.setVolume(volume.coerceIn(0f, 1f))
97
+ audioTrack.play()
98
+
99
+ feeder =
100
+ Thread({ feedLoop() }, "PcmStreamPlayer-$streamId").apply {
101
+ isDaemon = true
102
+ start()
103
+ }
104
+ Log.d(TAG, "[$streamId] opened: rate=$sampleRate ch=$channels vol=$volume")
105
+ }
106
+
107
+ /** Milliseconds of audio accepted but not yet played out. */
108
+ private fun backlogMsLocked(): Long {
109
+ val playedFrames = audioTrack.playbackHeadPosition.toLong() and 0xFFFFFFFFL
110
+ val queuedFrames = queuedBytes / bytesPerFrame
111
+ return max(0L, (framesWritten - playedFrames + queuedFrames)) * 1000L / sampleRate
112
+ }
113
+
114
+ /**
115
+ * Append base64-encoded 16-bit LE PCM. Blocks while the backlog is above
116
+ * the backpressure ceiling. Returns the backlog in ms after this chunk.
117
+ */
118
+ fun write(base64: String): Long {
119
+ val bytes = Base64.decode(base64, Base64.DEFAULT)
120
+ require(bytes.isNotEmpty()) { "PCM chunk is empty" }
121
+ require(bytes.size % bytesPerFrame == 0) {
122
+ "PCM chunk length ${bytes.size} is not aligned to a $bytesPerFrame-byte frame"
123
+ }
124
+ lock.withLock {
125
+ check(!closing && !aborted) { "stream is closed" }
126
+ endedError?.let { throw IllegalStateException(it) }
127
+ check(backlogMsLocked() + bytes.size / bytesPerFrame * 1000L / sampleRate <= MAX_BACKLOG_MS) {
128
+ "backlog exceeds ${MAX_BACKLOG_MS}ms — producer is not throttling"
129
+ }
130
+ queue.add(bytes)
131
+ queuedBytes += bytes.size
132
+ queueChanged.signalAll()
133
+ // Backpressure: hold the caller until the feeder drains below the
134
+ // ceiling. Caller is a background dispatcher, never the UI thread.
135
+ while (backlogMsLocked() > BACKPRESSURE_CEILING_MS && !closing && !aborted && endedError == null) {
136
+ queueChanged.await()
137
+ }
138
+ endedError?.let { throw IllegalStateException(it) }
139
+ return backlogMsLocked()
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Drain everything queued, then stop. Blocks until played out. Returns the
145
+ * total played duration in ms.
146
+ */
147
+ fun close(): Long {
148
+ lock.withLock {
149
+ if (aborted) return playedMsLocked()
150
+ closing = true
151
+ queueChanged.signalAll()
152
+ // Wait for the feeder to hand everything to the track...
153
+ while ((queue.isNotEmpty() || writeInProgress) && endedError == null && !aborted) {
154
+ queueChanged.await()
155
+ }
156
+ }
157
+ // ...then for the track to play it out while AudioTrack is still in
158
+ // PLAYSTATE_PLAYING. Calling stop() first flushes pending MODE_STREAM
159
+ // audio on several Android builds and truncates the final chunk.
160
+ try {
161
+ val deadline = System.currentTimeMillis() + MAX_BACKLOG_MS
162
+ while (System.currentTimeMillis() < deadline) {
163
+ val drained =
164
+ lock.withLock {
165
+ val played = audioTrack.playbackHeadPosition.toLong() and 0xFFFFFFFFL
166
+ played >= framesWritten || endedError != null
167
+ }
168
+ if (drained) break
169
+ Thread.sleep(20)
170
+ }
171
+ audioTrack.stop()
172
+ } catch (e: Exception) {
173
+ Log.w(TAG, "[$streamId] drain-on-close failed", e)
174
+ } finally {
175
+ releaseInternals()
176
+ }
177
+ return playedMsLocked()
178
+ }
179
+
180
+ /** Stop now, dropping the backlog. Idempotent. */
181
+ fun abort() {
182
+ lock.withLock {
183
+ if (aborted) return
184
+ aborted = true
185
+ queue.clear()
186
+ queuedBytes = 0
187
+ queueChanged.signalAll()
188
+ }
189
+ try {
190
+ audioTrack.pause()
191
+ audioTrack.flush()
192
+ } catch (e: Exception) {
193
+ Log.w(TAG, "[$streamId] abort flush failed", e)
194
+ }
195
+ releaseInternals()
196
+ Log.d(TAG, "[$streamId] aborted")
197
+ }
198
+
199
+ /** Played duration so far, in ms. */
200
+ fun playedMs(): Long = lock.withLock { playedMsLocked() }
201
+
202
+ private fun playedMsLocked(): Long {
203
+ val played =
204
+ finalPlayedFrames
205
+ ?: try {
206
+ audioTrack.playbackHeadPosition.toLong() and 0xFFFFFFFFL
207
+ } catch (e: Exception) {
208
+ framesWritten
209
+ }
210
+ return played * 1000L / sampleRate
211
+ }
212
+
213
+ private fun feedLoop() {
214
+ try {
215
+ while (true) {
216
+ val chunk: ByteArray =
217
+ lock.withLock {
218
+ while (queue.isEmpty() && !closing && !aborted) {
219
+ queueChanged.await()
220
+ }
221
+ if (aborted) return
222
+ if (queue.isEmpty() && closing) return
223
+ queue.poll()!!.also { writeInProgress = true }
224
+ }
225
+ // Blocking write OUTSIDE the lock so write()/abort() stay responsive.
226
+ var off = 0
227
+ while (off < chunk.size) {
228
+ val n = audioTrack.write(chunk, off, chunk.size - off)
229
+ if (n < 0) throw IllegalStateException("AudioTrack.write error $n")
230
+ off += n
231
+ }
232
+ lock.withLock {
233
+ queuedBytes -= chunk.size
234
+ framesWritten += chunk.size / bytesPerFrame
235
+ writeInProgress = false
236
+ queueChanged.signalAll()
237
+ }
238
+ }
239
+ } catch (e: Exception) {
240
+ if (!aborted) {
241
+ Log.e(TAG, "[$streamId] feeder failed", e)
242
+ lock.withLock {
243
+ endedError = e.message ?: "feeder failed"
244
+ queue.clear()
245
+ queuedBytes = 0
246
+ writeInProgress = false
247
+ queueChanged.signalAll()
248
+ }
249
+ }
250
+ } finally {
251
+ lock.withLock { queueChanged.signalAll() }
252
+ }
253
+ }
254
+
255
+ private fun releaseInternals() {
256
+ val shouldRelease =
257
+ lock.withLock {
258
+ if (released) {
259
+ false
260
+ } else {
261
+ finalPlayedFrames =
262
+ try {
263
+ audioTrack.playbackHeadPosition.toLong() and 0xFFFFFFFFL
264
+ } catch (_: Exception) {
265
+ framesWritten
266
+ }
267
+ released = true
268
+ true
269
+ }
270
+ }
271
+ if (!shouldRelease) return
272
+ try {
273
+ audioTrack.release()
274
+ } catch (e: Exception) {
275
+ Log.w(TAG, "[$streamId] release failed", e)
276
+ }
277
+ }
278
+ }
279
+
280
+ /**
281
+ * Registry of active [PcmStreamPlayer] sessions, keyed by streamId. All
282
+ * methods are safe to call with unknown ids (write throws, abort no-ops) so
283
+ * races with teardown resolve cleanly.
284
+ */
285
+ object PcmStreamManager {
286
+ private val players = java.util.concurrent.ConcurrentHashMap<String, PcmStreamPlayer>()
287
+
288
+ fun open(streamId: String, sampleRate: Int, channels: Int, volume: Float) {
289
+ require(streamId.isNotBlank()) { "streamId is required" }
290
+ require(sampleRate == 16000 || sampleRate == 24000 || sampleRate == 48000) {
291
+ "unsupported PCM sample rate $sampleRate"
292
+ }
293
+ require(channels == 1) { "only mono PCM is supported" }
294
+ // Replacing an id is a caller bug, but never leak the old track.
295
+ players.remove(streamId)?.abort()
296
+ players[streamId] = PcmStreamPlayer(streamId, sampleRate, channels, volume)
297
+ }
298
+
299
+ fun write(streamId: String, base64: String): Long {
300
+ val player =
301
+ players[streamId] ?: throw IllegalStateException("pcm stream $streamId is not open")
302
+ return player.write(base64)
303
+ }
304
+
305
+ fun close(streamId: String): Long {
306
+ val player =
307
+ players[streamId]
308
+ ?: throw IllegalStateException("pcm stream $streamId is not open")
309
+ return try {
310
+ player.close()
311
+ } finally {
312
+ // Leave a draining player addressable so teardown can still abort
313
+ // it. The conditional remove preserves a replacement with the
314
+ // same id if one was opened while this close was completing.
315
+ players.remove(streamId, player)
316
+ }
317
+ }
318
+
319
+ fun abort(streamId: String) {
320
+ players.remove(streamId)?.abort()
321
+ }
322
+
323
+ fun abortAll() {
324
+ val active = players.values.toList()
325
+ players.clear()
326
+ active.forEach { it.abort() }
327
+ }
328
+ }
@@ -36,6 +36,7 @@ class PhotoRequestTest {
36
36
 
37
37
  assertThat(request.exposureTimeNs).isNull()
38
38
  assertThat(request.mode).isEqualTo(PhotoMode.PHOTO)
39
+ assertThat(request.transferMethod).isEqualTo("auto")
39
40
  }
40
41
 
41
42
  @Test
@@ -93,4 +94,18 @@ class PhotoRequestTest {
93
94
 
94
95
  assertThat(request.mode).isEqualTo(PhotoMode.TEXT)
95
96
  }
97
+
98
+ @Test
99
+ fun `fromMap preserves forced BLE transfer`() {
100
+ val request =
101
+ PhotoRequest.fromMap(
102
+ mapOf(
103
+ "size" to "medium",
104
+ "transferMethod" to "ble",
105
+ "webhookUrl" to "https://example.com/upload",
106
+ )
107
+ )
108
+
109
+ assertThat(request.transferMethod).isEqualTo("ble")
110
+ }
96
111
  }
@@ -88,6 +88,16 @@ export type ConnectedWifiStatus = Extract<WifiStatus, {
88
88
  export declare function isConnectedWifiStatus(status: WifiStatus): status is ConnectedWifiStatus;
89
89
  export type WifiStatusChangeEvent = WifiStatus & {
90
90
  type: "wifi_status_change";
91
+ /**
92
+ * Glasses-reported provisioning failure reason when THIS event is the verdict of a
93
+ * failed connect attempt; absent on routine link-state updates. An attempt property,
94
+ * not a link property — which is why it lives on the event, not on WifiStatus:
95
+ * "connect_timeout" arrives on a disconnected status (never associated), while
96
+ * "connected_to_other_network" arrives on a *connected* status (the attempt failed
97
+ * and the glasses ended up on / fell back to a different SSID than requested).
98
+ * Requires ASG client v40+ — older glasses never send it.
99
+ */
100
+ error?: string;
91
101
  };
92
102
  export type HotspotStatus = {
93
103
  state: "disabled";
@@ -317,7 +327,7 @@ export type RgbLedControlSuccessResponseEvent = Extract<RgbLedControlResponseEve
317
327
  state: "success";
318
328
  }>;
319
329
  export type SettingsAckStatus = "applied" | "ready" | "error" | "failed" | "failure" | "rejected";
320
- export type SettingsAckSetting = "gallery_mode" | "button_photo" | "button_video_recording" | "button_max_recording_time" | "camera_fov" | "camera_tuning";
330
+ export type SettingsAckSetting = "gallery_mode" | "button_photo" | "button_video_recording" | "button_max_recording_time" | "camera_fov" | "camera_fov_override" | "camera_tuning";
321
331
  export type SettingsAckEvent = {
322
332
  type: "settings_ack";
323
333
  requestId: string;
@@ -327,6 +337,7 @@ export type SettingsAckEvent = {
327
337
  fov?: number;
328
338
  roiPosition?: CameraRoiPositionValue;
329
339
  hardwareApplied?: boolean;
340
+ leaseId?: string;
330
341
  active?: boolean;
331
342
  size?: ButtonPhotoSize | string;
332
343
  width?: number;
@@ -429,6 +440,12 @@ export type CameraFovResult = {
429
440
  roiPosition: CameraRoiPosition;
430
441
  timestamp: number;
431
442
  };
443
+ export type CameraFovOverrideRequest = CameraFovRequest & {
444
+ /** Phone-owned lease used to make delayed releases safe. */
445
+ leaseId: string;
446
+ /** Safety TTL; refresh the same lease/configuration to extend without a HAL restart. */
447
+ ttlMs?: number;
448
+ };
432
449
  export type CameraFovSetting = {
433
450
  fov: number;
434
451
  roiPosition: CameraRoiPositionValue;
@@ -444,6 +461,8 @@ export type PhotoRequestParams = {
444
461
  appId?: string;
445
462
  size: PhotoSize;
446
463
  mode?: PhotoMode;
464
+ /** `ble` skips direct upload and forces phone-relayed BLE image transfer. */
465
+ transferMethod?: "auto" | "ble";
447
466
  webhookUrl: string | null;
448
467
  authToken: string | null;
449
468
  compress: PhotoCompression;
@@ -465,9 +484,12 @@ export type PhotoRequestParams = {
465
484
  ispAnalogGain?: string;
466
485
  };
467
486
  export type WarmUpCameraParams = {
487
+ /** Supply this when the owner needs to call stopCameraWarmUp during teardown. */
468
488
  requestId?: string;
469
489
  size: PhotoSize;
490
+ mode?: PhotoMode;
470
491
  exposureTimeNs?: number | null;
492
+ /** Ready-state hold; defaults to 15 seconds and is capped at 60 seconds by ASG. */
471
493
  durationMs?: number;
472
494
  };
473
495
  export type StreamVideoConfig = {
@@ -695,6 +717,8 @@ export type BluetoothSdkModuleEvents = {
695
717
  speaking_status: (event: SpeakingStatusEvent) => void;
696
718
  battery_status: (event: BatteryStatusEvent) => void;
697
719
  local_transcription: (event: LocalTranscriptionEvent) => void;
720
+ phone_notification: (event: PhoneNotificationEvent) => void;
721
+ phone_notification_dismissed: (event: PhoneNotificationDismissedEvent) => void;
698
722
  wifi_status_change: (event: WifiStatusChangeEvent) => void;
699
723
  wifi_scan_result: (event: WifiScanResultEvent) => void;
700
724
  hotspot_status_change: (event: HotspotStatusChangeEvent) => void;
@@ -739,6 +763,21 @@ export interface ExtractionProgressEvent {
739
763
  bytesRead: number;
740
764
  totalBytes: number;
741
765
  }
766
+ export interface PhoneNotificationEvent {
767
+ notificationId: string;
768
+ app: string;
769
+ title: string;
770
+ content: string;
771
+ priority: string;
772
+ timestamp: number;
773
+ packageName: string;
774
+ }
775
+ export interface PhoneNotificationDismissedEvent {
776
+ notificationId: string;
777
+ notificationKey: string;
778
+ packageName: string;
779
+ timestamp: number;
780
+ }
742
781
  export type PublicGlassesStatus = Omit<GlassesStatus, "otaUpdateAvailable" | "otaProgress" | "otaInProgress" | "otaVersionUrl">;
743
782
  export type PublicBluetoothStatus = Pick<BluetoothStatus, "searching" | "searchingController" | "systemMicUnavailable" | "micRanking" | "currentMic" | "searchResults" | "wifiScanResults" | "lastLog" | "otherBtConnected" | "galleryModeEnabled">;
744
783
  export type BluetoothSdkEventMap = {
@@ -827,6 +866,10 @@ export interface BluetoothSdkPublicModule {
827
866
  setVideoRecordingDefaults(settings: VideoRecordingDefaults): Promise<SettingsAckSuccessEvent>;
828
867
  setMaxVideoRecordingDuration(minutes: number): Promise<SettingsAckSuccessEvent>;
829
868
  setCameraFov(request: CameraFovRequest): Promise<CameraFovResult>;
869
+ /** One-way FOV command for legacy ASG clients that do not send settings acknowledgements. */
870
+ setLegacyCameraFov(request: CameraFovRequest): Promise<CameraFovResult>;
871
+ setCameraFovOverride(request: CameraFovOverrideRequest): Promise<CameraFovResult>;
872
+ releaseCameraFovOverride(leaseId: string): Promise<SettingsAckSuccessEvent>;
830
873
  /**
831
874
  * Configure camera HAL tuning (ANR / gain) on Mentra Live glasses.
832
875
  *
@@ -844,6 +887,8 @@ export interface BluetoothSdkPublicModule {
844
887
  queryGalleryStatus(): Promise<GalleryStatusEvent>;
845
888
  requestPhoto(params: PhotoRequestParams): Promise<PhotoSuccessResponseEvent>;
846
889
  warmUpCamera(params: WarmUpCameraParams): Promise<CameraStatusEvent>;
890
+ /** Release one request-owned warm-up. Opening requests reject with camera_warm_up_cancelled. */
891
+ stopCameraWarmUp(requestId: string): Promise<void>;
847
892
  startVideoRecording(requestId: string, save: boolean, sound: boolean, settings?: VideoRecordingSettings): Promise<VideoRecordingStartedStatusEvent>;
848
893
  /**
849
894
  * Stop the active recording. When {@link webhookUrl} is provided, the glasses