@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.
- package/README.md +5 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +127 -24
- package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +65 -14
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +46 -3
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +369 -97
- package/android/src/main/java/com/mentra/bluetoothsdk/MentraLocalNetworkModule.kt +333 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/camera/CameraModels.kt +3 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.kt +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +16 -9
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Mach1.kt +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +183 -30
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Nimo.kt +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +4 -2
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Simulated.kt +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/status/WifiHotspotStatus.kt +19 -3
- package/android/src/main/java/com/mentra/bluetoothsdk/streaming/StreamModels.kt +15 -2
- package/android/src/main/java/com/mentra/bluetoothsdk/tts/TTSTools.kt +115 -47
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/audio/PcmStreamPlayer.kt +328 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/camera/PhotoRequestTest.kt +15 -0
- package/build/BluetoothSdk.types.d.ts +46 -1
- package/build/BluetoothSdk.types.d.ts.map +1 -1
- package/build/BluetoothSdk.types.js.map +1 -1
- package/build/_internal.d.ts +2 -0
- package/build/_internal.d.ts.map +1 -1
- package/build/_internal.js +2 -0
- package/build/_internal.js.map +1 -1
- package/build/_private/BluetoothSdkModule.d.ts +23 -2
- package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
- package/build/_private/BluetoothSdkModule.js +20 -0
- package/build/_private/BluetoothSdkModule.js.map +1 -1
- package/build/_private/MentraLocalNetworkModule.d.ts +37 -0
- package/build/_private/MentraLocalNetworkModule.d.ts.map +1 -0
- package/build/_private/MentraLocalNetworkModule.js +3 -0
- package/build/_private/MentraLocalNetworkModule.js.map +1 -0
- package/build/_private/cameraRequestPayload.d.ts.map +1 -1
- package/build/_private/cameraRequestPayload.js +1 -0
- package/build/_private/cameraRequestPayload.js.map +1 -1
- package/build/_private/photoRequestPayload.d.ts.map +1 -1
- package/build/_private/photoRequestPayload.js +1 -0
- package/build/_private/photoRequestPayload.js.map +1 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js +4 -0
- package/build/index.js.map +1 -1
- package/expo-module.config.json +1 -0
- package/ios/BluetoothSdkModule.swift +72 -0
- package/ios/Source/BluetoothSdkDefaults.swift +1 -1
- package/ios/Source/Bridge.swift +72 -18
- package/ios/Source/DeviceManager.swift +51 -3
- package/ios/Source/DeviceStore.swift +1 -1
- package/ios/Source/MentraBluetoothSDK.swift +289 -25
- package/ios/Source/audio/PcmStreamPlayer.swift +410 -0
- package/ios/Source/camera/CameraModels.swift +8 -3
- package/ios/Source/sgcs/G1.swift +1 -1
- package/ios/Source/sgcs/G2.swift +19 -7
- package/ios/Source/sgcs/Mach1.swift +1 -1
- package/ios/Source/sgcs/MentraLive.swift +318 -37
- package/ios/Source/sgcs/MentraNex.swift +1 -1
- package/ios/Source/sgcs/Nimo.swift +1 -1
- package/ios/Source/sgcs/SGCManager.swift +5 -2
- package/ios/Source/sgcs/Simulated.swift +1 -1
- package/ios/Source/status/WifiHotspotStatus.swift +21 -2
- package/ios/Source/streaming/StreamModels.swift +9 -0
- package/ios/Source/tts/TTSTools.swift +1 -1
- package/package.json +1 -2
- package/src/BluetoothSdk.types.ts +49 -0
- package/src/_internal.ts +2 -0
- package/src/_private/BluetoothSdkModule.ts +64 -13
- package/src/_private/MentraLocalNetworkModule.ts +50 -0
- package/src/_private/cameraRequestPayload.ts +1 -0
- package/src/_private/photoRequestPayload.ts +2 -3
- 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
|
-
|
|
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
|
|
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("
|
|
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
|