@mentra/acs-meeting 3.2.0-dev.120
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/NOTICE +20 -0
- package/README.md +412 -0
- package/THIRD_PARTY +7 -0
- package/android/build.gradle +64 -0
- package/android/src/main/AndroidManifest.xml +5 -0
- package/android/src/main/java/com/mentra/acsmeeting/AcsMeetingModule.kt +80 -0
- package/android/src/main/java/com/mentra/acsmeeting/AcsMeetingSession.kt +846 -0
- package/android/src/main/java/com/mentra/acsmeeting/RemoteRoster.kt +123 -0
- package/android/src/main/java/com/mentra/acsmeeting/audio/AcsAudioPolicy.kt +90 -0
- package/android/src/main/java/com/mentra/acsmeeting/audio/AudioPolicyApplier.kt +176 -0
- package/android/src/main/java/com/mentra/acsmeeting/audio/IncomingAudioPump.kt +112 -0
- package/android/src/main/java/com/mentra/acsmeeting/audio/IncomingRateProbe.kt +65 -0
- package/android/src/main/java/com/mentra/acsmeeting/audio/PcmBridge.kt +108 -0
- package/android/src/main/java/com/mentra/acsmeeting/audio/PcmResampler.kt +159 -0
- package/android/src/main/java/com/mentra/acsmeeting/audio/PhoneMicCapturer.kt +115 -0
- package/android/src/main/java/com/mentra/acsmeeting/audio/UplinkPacer.kt +302 -0
- package/android/src/main/java/com/mentra/acsmeeting/audio/UplinkSender.kt +230 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/BitmapFont.kt +99 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/CloudflareWhepSource.kt +578 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/FirstFrameGate.kt +45 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/GlassesMediaSource.kt +101 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/MediaListeners.kt +57 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/SyntheticFrameFactory.kt +205 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/SyntheticI420Source.kt +214 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/TrackRegistry.kt +31 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/VideoSourceArm.kt +41 -0
- package/android/src/main/java/com/mentra/acsmeeting/telemetry/ChromaProbe.kt +78 -0
- package/android/src/main/java/com/mentra/acsmeeting/telemetry/PipelineStats.kt +281 -0
- package/android/src/main/java/com/mentra/acsmeeting/telemetry/PipelineTicker.kt +51 -0
- package/android/src/main/java/com/mentra/acsmeeting/telemetry/ProcessCpu.kt +15 -0
- package/android/src/main/java/com/mentra/acsmeeting/telemetry/RingPercentile.kt +33 -0
- package/android/src/main/java/com/mentra/acsmeeting/video/AcsFrameSender.kt +475 -0
- package/android/src/main/java/com/mentra/acsmeeting/video/AcsTimestamp.kt +26 -0
- package/android/src/main/java/com/mentra/acsmeeting/video/FrameGeometry.kt +22 -0
- package/android/src/main/java/com/mentra/acsmeeting/video/I420FormatSpec.kt +90 -0
- package/android/src/main/java/com/mentra/acsmeeting/video/I420Packer.kt +72 -0
- package/android/src/main/java/com/mentra/acsmeeting/video/Nv12FormatSpec.kt +36 -0
- package/android/src/main/java/com/mentra/acsmeeting/video/Nv12Packer.kt +51 -0
- package/android/src/main/java/com/mentra/acsmeeting/video/SendGate.kt +27 -0
- package/android/src/main/java/com/mentra/acsmeeting/video/VideoProfile.kt +57 -0
- package/android/src/test/java/com/mentra/acsmeeting/RemoteRosterTest.kt +40 -0
- package/android/src/test/java/com/mentra/acsmeeting/audio/AcsAudioPolicyTest.kt +204 -0
- package/android/src/test/java/com/mentra/acsmeeting/audio/AudioPolicyApplierTest.kt +223 -0
- package/android/src/test/java/com/mentra/acsmeeting/audio/CapturePolicyTest.kt +12 -0
- package/android/src/test/java/com/mentra/acsmeeting/audio/IncomingAudioPumpTest.kt +97 -0
- package/android/src/test/java/com/mentra/acsmeeting/audio/IncomingRateProbeTest.kt +78 -0
- package/android/src/test/java/com/mentra/acsmeeting/audio/PcmResamplerTest.kt +125 -0
- package/android/src/test/java/com/mentra/acsmeeting/audio/UplinkPacerTest.kt +201 -0
- package/android/src/test/java/com/mentra/acsmeeting/audio/UplinkSenderTest.kt +166 -0
- package/android/src/test/java/com/mentra/acsmeeting/source/AcsInvestigationTest.kt +26 -0
- package/android/src/test/java/com/mentra/acsmeeting/source/BitmapFontTest.kt +22 -0
- package/android/src/test/java/com/mentra/acsmeeting/source/FirstFrameGateTest.kt +60 -0
- package/android/src/test/java/com/mentra/acsmeeting/source/GlassesMediaSourceTest.kt +133 -0
- package/android/src/test/java/com/mentra/acsmeeting/source/I420PlanesTest.kt +67 -0
- package/android/src/test/java/com/mentra/acsmeeting/source/SyntheticFrameFactoryTest.kt +193 -0
- package/android/src/test/java/com/mentra/acsmeeting/source/SyntheticI420SourceTest.kt +176 -0
- package/android/src/test/java/com/mentra/acsmeeting/source/TrackRegistryTest.kt +36 -0
- package/android/src/test/java/com/mentra/acsmeeting/telemetry/ChromaProbeTest.kt +61 -0
- package/android/src/test/java/com/mentra/acsmeeting/telemetry/PipelineStatsTest.kt +256 -0
- package/android/src/test/java/com/mentra/acsmeeting/telemetry/ProcessCpuTest.kt +18 -0
- package/android/src/test/java/com/mentra/acsmeeting/telemetry/RingPercentileTest.kt +49 -0
- package/android/src/test/java/com/mentra/acsmeeting/video/AcsTimestampTest.kt +43 -0
- package/android/src/test/java/com/mentra/acsmeeting/video/FrameGeometryTest.kt +33 -0
- package/android/src/test/java/com/mentra/acsmeeting/video/I420FormatSpecTest.kt +77 -0
- package/android/src/test/java/com/mentra/acsmeeting/video/I420PackerTest.kt +126 -0
- package/android/src/test/java/com/mentra/acsmeeting/video/Nv12FormatSpecTest.kt +36 -0
- package/android/src/test/java/com/mentra/acsmeeting/video/Nv12PackerTest.kt +58 -0
- package/android/src/test/java/com/mentra/acsmeeting/video/SendGateTest.kt +79 -0
- package/android/src/test/java/com/mentra/acsmeeting/video/VideoProfileTest.kt +68 -0
- package/build/AcsMeeting.types.d.ts +59 -0
- package/build/AcsMeeting.types.d.ts.map +1 -0
- package/build/AcsMeeting.types.js +2 -0
- package/build/AcsMeeting.types.js.map +1 -0
- package/build/AcsMeetingModule.d.ts +15 -0
- package/build/AcsMeetingModule.d.ts.map +1 -0
- package/build/AcsMeetingModule.js +3 -0
- package/build/AcsMeetingModule.js.map +1 -0
- package/build/AcsMeetingModule.web.d.ts +16 -0
- package/build/AcsMeetingModule.web.d.ts.map +1 -0
- package/build/AcsMeetingModule.web.js +31 -0
- package/build/AcsMeetingModule.web.js.map +1 -0
- package/build/index.d.ts +3 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +3 -0
- package/build/index.js.map +1 -0
- package/expo-module.config.json +9 -0
- package/ios/AcsFrameSender.swift +47 -0
- package/ios/AcsMeeting.podspec +27 -0
- package/ios/AcsMeetingModule.swift +558 -0
- package/ios/PcmBridge.swift +117 -0
- package/ios/PhoneMicCapturer.swift +64 -0
- package/ios/PolicyKit/Package.swift +17 -0
- package/ios/PolicyKit/Sources/AcsAudioPolicy/AcsAudioPolicy.swift +102 -0
- package/ios/PolicyKit/Sources/AcsAudioPolicy/AudioPolicyApplier.swift +162 -0
- package/ios/PolicyKit/Sources/AcsAudioPolicy/GlassesMediaSource.swift +68 -0
- package/ios/PolicyKit/Tests/AcsAudioPolicyTests/AcsAudioPolicyTests.swift +273 -0
- package/ios/WhepVideoSource.swift +356 -0
- package/package.json +56 -0
- package/src/AcsMeeting.types.ts +66 -0
- package/src/AcsMeetingModule.ts +16 -0
- package/src/AcsMeetingModule.web.ts +33 -0
- package/src/index.ts +2 -0
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
package com.mentra.acsmeeting.audio
|
|
2
|
+
|
|
3
|
+
import java.nio.ByteBuffer
|
|
4
|
+
import java.nio.ByteOrder
|
|
5
|
+
import kotlin.math.PI
|
|
6
|
+
import kotlin.math.cos
|
|
7
|
+
import kotlin.math.roundToInt
|
|
8
|
+
import kotlin.math.sin
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Stateful PCM16 downmix + sample-rate converter.
|
|
12
|
+
*
|
|
13
|
+
* WHEP Opus decodes at 48 kHz; ACS raw outgoing is also 48 kHz now, so the
|
|
14
|
+
* common path is downmix-only. If a side still crosses a rate boundary,
|
|
15
|
+
* picking samples without a low-pass filter aliases into the speech band.
|
|
16
|
+
* This filters first (windowed sinc at the input rate, cutoff just under
|
|
17
|
+
* the output Nyquist) and keeps filter history plus the fractional phase
|
|
18
|
+
* across calls, so chunked input produces the same samples as one
|
|
19
|
+
* contiguous buffer.
|
|
20
|
+
*/
|
|
21
|
+
class PcmResampler(
|
|
22
|
+
private val targetRate: Int,
|
|
23
|
+
) {
|
|
24
|
+
private var inRate = 0
|
|
25
|
+
private var inChannels = 0
|
|
26
|
+
private var taps = FloatArray(0)
|
|
27
|
+
/** Raw (unfiltered) input tail so the FIR sees taps.size - 1 samples of context. */
|
|
28
|
+
private var rawTail = FloatArray(0)
|
|
29
|
+
/** Filtered samples not yet consumed by the interpolator. */
|
|
30
|
+
private var history = FloatArray(0)
|
|
31
|
+
/** Position of the next output sample, in input-sample units, relative to the start of [history]. */
|
|
32
|
+
private var phase = 0.0
|
|
33
|
+
|
|
34
|
+
fun reset() {
|
|
35
|
+
inRate = 0
|
|
36
|
+
inChannels = 0
|
|
37
|
+
taps = FloatArray(0)
|
|
38
|
+
rawTail = FloatArray(0)
|
|
39
|
+
history = FloatArray(0)
|
|
40
|
+
phase = 0.0
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
fun process(pcm16Le: ByteArray, sampleRate: Int, channels: Int): ByteArray {
|
|
44
|
+
require(sampleRate > 0) { "sampleRate must be positive" }
|
|
45
|
+
require(channels > 0) { "channels must be positive" }
|
|
46
|
+
if (sampleRate != inRate || channels != inChannels) configure(sampleRate, channels)
|
|
47
|
+
|
|
48
|
+
val mono = downmix(pcm16Le, channels)
|
|
49
|
+
if (sampleRate == targetRate) return toBytes(mono)
|
|
50
|
+
|
|
51
|
+
// Filtered input signal = history tail + newly filtered samples. The
|
|
52
|
+
// history keeps taps.size - 1 raw samples so the FIR has full context,
|
|
53
|
+
// plus one filtered sample so linear interpolation can straddle the seam.
|
|
54
|
+
val filtered = if (taps.isEmpty()) mono else lowPass(mono)
|
|
55
|
+
val signal = FloatArray(history.size + filtered.size)
|
|
56
|
+
System.arraycopy(history, 0, signal, 0, history.size)
|
|
57
|
+
System.arraycopy(filtered, 0, signal, history.size, filtered.size)
|
|
58
|
+
|
|
59
|
+
val step = sampleRate.toDouble() / targetRate
|
|
60
|
+
val capacity = maxOf(0, ((signal.size - phase) / step).toInt() + 2)
|
|
61
|
+
val out = ShortArray(capacity)
|
|
62
|
+
var count = 0
|
|
63
|
+
var pos = phase
|
|
64
|
+
while (pos + 1 < signal.size) {
|
|
65
|
+
val i = pos.toInt()
|
|
66
|
+
val frac = (pos - i).toFloat()
|
|
67
|
+
val sample = signal[i] + (signal[i + 1] - signal[i]) * frac
|
|
68
|
+
out[count++] = clamp(sample)
|
|
69
|
+
pos += step
|
|
70
|
+
}
|
|
71
|
+
// Keep everything from the next output position onward so the seam interpolates.
|
|
72
|
+
val keepFrom = maxOf(0, minOf(pos.toInt(), signal.size - 1))
|
|
73
|
+
history = signal.copyOfRange(keepFrom, signal.size)
|
|
74
|
+
phase = pos - keepFrom
|
|
75
|
+
|
|
76
|
+
val result = ByteArray(count * 2)
|
|
77
|
+
val bb = ByteBuffer.wrap(result).order(ByteOrder.LITTLE_ENDIAN)
|
|
78
|
+
for (i in 0 until count) bb.putShort(out[i])
|
|
79
|
+
return result
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
private fun configure(sampleRate: Int, channels: Int) {
|
|
83
|
+
inRate = sampleRate
|
|
84
|
+
inChannels = channels
|
|
85
|
+
history = FloatArray(0)
|
|
86
|
+
rawTail = FloatArray(0)
|
|
87
|
+
phase = 0.0
|
|
88
|
+
taps = if (sampleRate > targetRate) designLowPass(sampleRate, targetRate) else FloatArray(0)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
private fun lowPass(mono: FloatArray): FloatArray {
|
|
92
|
+
val n = taps.size
|
|
93
|
+
val input = FloatArray(rawTail.size + mono.size)
|
|
94
|
+
System.arraycopy(rawTail, 0, input, 0, rawTail.size)
|
|
95
|
+
System.arraycopy(mono, 0, input, rawTail.size, mono.size)
|
|
96
|
+
val outCount = input.size - (n - 1)
|
|
97
|
+
if (outCount <= 0) {
|
|
98
|
+
rawTail = input
|
|
99
|
+
return FloatArray(0)
|
|
100
|
+
}
|
|
101
|
+
val out = FloatArray(outCount)
|
|
102
|
+
for (i in 0 until outCount) {
|
|
103
|
+
var acc = 0f
|
|
104
|
+
for (k in 0 until n) acc += input[i + k] * taps[k]
|
|
105
|
+
out[i] = acc
|
|
106
|
+
}
|
|
107
|
+
rawTail = input.copyOfRange(input.size - (n - 1), input.size)
|
|
108
|
+
return out
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
companion object {
|
|
112
|
+
/** Odd tap count keeps the group delay an integer number of samples. */
|
|
113
|
+
private const val TAPS = 63
|
|
114
|
+
|
|
115
|
+
fun designLowPass(sampleRate: Int, targetRate: Int): FloatArray {
|
|
116
|
+
// Cutoff at 90% of the output Nyquist: full speech band through, the
|
|
117
|
+
// transition band finished before anything can alias.
|
|
118
|
+
val cutoff = 0.45 * targetRate / sampleRate
|
|
119
|
+
val m = (TAPS - 1) / 2
|
|
120
|
+
val taps = FloatArray(TAPS)
|
|
121
|
+
var sum = 0.0
|
|
122
|
+
for (i in 0 until TAPS) {
|
|
123
|
+
val x = (i - m).toDouble()
|
|
124
|
+
val sinc = if (x == 0.0) 2 * cutoff else sin(2 * PI * cutoff * x) / (PI * x)
|
|
125
|
+
val window = 0.54 - 0.46 * cos(2 * PI * i / (TAPS - 1))
|
|
126
|
+
val h = sinc * window
|
|
127
|
+
taps[i] = h.toFloat()
|
|
128
|
+
sum += h
|
|
129
|
+
}
|
|
130
|
+
for (i in 0 until TAPS) taps[i] = (taps[i] / sum).toFloat()
|
|
131
|
+
return taps
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
fun downmix(pcm16Le: ByteArray, channels: Int): FloatArray {
|
|
135
|
+
val frames = pcm16Le.size / (2 * channels)
|
|
136
|
+
val shorts = ByteBuffer.wrap(pcm16Le, 0, frames * 2 * channels).order(ByteOrder.LITTLE_ENDIAN).asShortBuffer()
|
|
137
|
+
val mono = FloatArray(frames)
|
|
138
|
+
if (channels == 1) {
|
|
139
|
+
for (i in 0 until frames) mono[i] = shorts.get(i).toFloat()
|
|
140
|
+
} else {
|
|
141
|
+
for (i in 0 until frames) {
|
|
142
|
+
var acc = 0f
|
|
143
|
+
for (c in 0 until channels) acc += shorts.get(i * channels + c)
|
|
144
|
+
mono[i] = acc / channels
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return mono
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
private fun clamp(v: Float): Short = v.roundToInt().coerceIn(Short.MIN_VALUE.toInt(), Short.MAX_VALUE.toInt()).toShort()
|
|
151
|
+
|
|
152
|
+
private fun toBytes(mono: FloatArray): ByteArray {
|
|
153
|
+
val out = ByteArray(mono.size * 2)
|
|
154
|
+
val bb = ByteBuffer.wrap(out).order(ByteOrder.LITTLE_ENDIAN)
|
|
155
|
+
for (v in mono) bb.putShort(clamp(v))
|
|
156
|
+
return out
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
package com.mentra.acsmeeting.audio
|
|
2
|
+
|
|
3
|
+
import android.annotation.SuppressLint
|
|
4
|
+
import android.media.AudioFormat
|
|
5
|
+
import android.media.AudioRecord
|
|
6
|
+
import android.media.MediaRecorder
|
|
7
|
+
import android.os.Process
|
|
8
|
+
import android.util.Log
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Phone-mic uplink for ACS virtual outgoing. Uses [AudioRecord] MIC at 48 kHz
|
|
12
|
+
* mono PCM16 and never touches [android.media.AudioManager] mode — ACS
|
|
13
|
+
* communication mode stays off so A2DP playback remains on the glasses.
|
|
14
|
+
*/
|
|
15
|
+
class PhoneMicCapturer(
|
|
16
|
+
private val onPcm: (ByteArray, Int, Int) -> Unit,
|
|
17
|
+
) {
|
|
18
|
+
@Volatile private var record: AudioRecord? = null
|
|
19
|
+
private var thread: Thread? = null
|
|
20
|
+
|
|
21
|
+
@Synchronized
|
|
22
|
+
fun setEnabled(enabled: Boolean) {
|
|
23
|
+
if (enabled) start() else stop()
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
@SuppressLint("MissingPermission")
|
|
27
|
+
private fun start() {
|
|
28
|
+
if (record != null) return
|
|
29
|
+
val min = AudioRecord.getMinBufferSize(SAMPLE_RATE, CHANNEL, ENCODING)
|
|
30
|
+
if (min == AudioRecord.ERROR || min == AudioRecord.ERROR_BAD_VALUE) {
|
|
31
|
+
Log.w(TAG, "PhoneMicCapturer: bad min buffer $min")
|
|
32
|
+
return
|
|
33
|
+
}
|
|
34
|
+
val bufferSize = maxOf(min, FRAME_BYTES * 4)
|
|
35
|
+
val rec = try {
|
|
36
|
+
AudioRecord.Builder()
|
|
37
|
+
.setAudioSource(MediaRecorder.AudioSource.MIC)
|
|
38
|
+
.setAudioFormat(
|
|
39
|
+
AudioFormat.Builder()
|
|
40
|
+
.setSampleRate(SAMPLE_RATE)
|
|
41
|
+
.setChannelMask(CHANNEL)
|
|
42
|
+
.setEncoding(ENCODING)
|
|
43
|
+
.build(),
|
|
44
|
+
)
|
|
45
|
+
.setBufferSizeInBytes(bufferSize)
|
|
46
|
+
.build()
|
|
47
|
+
} catch (error: Exception) {
|
|
48
|
+
Log.w(TAG, "PhoneMicCapturer: create failed", error)
|
|
49
|
+
return
|
|
50
|
+
}
|
|
51
|
+
if (rec.state != AudioRecord.STATE_INITIALIZED) {
|
|
52
|
+
rec.release()
|
|
53
|
+
Log.w(TAG, "PhoneMicCapturer: AudioRecord failed to initialize")
|
|
54
|
+
return
|
|
55
|
+
}
|
|
56
|
+
try {
|
|
57
|
+
rec.startRecording()
|
|
58
|
+
} catch (error: Exception) {
|
|
59
|
+
rec.release()
|
|
60
|
+
Log.w(TAG, "PhoneMicCapturer: startRecording failed", error)
|
|
61
|
+
return
|
|
62
|
+
}
|
|
63
|
+
if (rec.recordingState != AudioRecord.RECORDSTATE_RECORDING) {
|
|
64
|
+
rec.release()
|
|
65
|
+
Log.w(TAG, "PhoneMicCapturer: did not enter RECORDING")
|
|
66
|
+
return
|
|
67
|
+
}
|
|
68
|
+
record = rec
|
|
69
|
+
thread = Thread({
|
|
70
|
+
Process.setThreadPriority(Process.THREAD_PRIORITY_AUDIO)
|
|
71
|
+
val buf = ByteArray(FRAME_BYTES)
|
|
72
|
+
while (true) {
|
|
73
|
+
val current = record ?: break
|
|
74
|
+
val n = try {
|
|
75
|
+
current.read(buf, 0, buf.size)
|
|
76
|
+
} catch (_: Exception) {
|
|
77
|
+
break
|
|
78
|
+
}
|
|
79
|
+
if (n <= 0) {
|
|
80
|
+
if (n < 0) break
|
|
81
|
+
continue
|
|
82
|
+
}
|
|
83
|
+
val copy = if (n == buf.size) buf.copyOf() else buf.copyOf(n)
|
|
84
|
+
onPcm(copy, SAMPLE_RATE, CHANNELS)
|
|
85
|
+
}
|
|
86
|
+
}, "acs-phone-mic").also { it.start() }
|
|
87
|
+
Log.i(TAG, "PhoneMicCapturer started 48kHz mono PCM16")
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
@Synchronized
|
|
91
|
+
fun stop() {
|
|
92
|
+
val rec = record ?: return
|
|
93
|
+
record = null
|
|
94
|
+
thread = null
|
|
95
|
+
try {
|
|
96
|
+
rec.stop()
|
|
97
|
+
} catch (_: Exception) {
|
|
98
|
+
}
|
|
99
|
+
try {
|
|
100
|
+
rec.release()
|
|
101
|
+
} catch (_: Exception) {
|
|
102
|
+
}
|
|
103
|
+
Log.i(TAG, "PhoneMicCapturer stopped")
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
companion object {
|
|
107
|
+
private const val TAG = "ACS-SPIKE"
|
|
108
|
+
const val SAMPLE_RATE = 48_000
|
|
109
|
+
const val CHANNELS = 1
|
|
110
|
+
private const val FRAME_MS = 20
|
|
111
|
+
private const val FRAME_BYTES = SAMPLE_RATE * CHANNELS * 2 * FRAME_MS / 1000
|
|
112
|
+
private const val CHANNEL = AudioFormat.CHANNEL_IN_MONO
|
|
113
|
+
private const val ENCODING = AudioFormat.ENCODING_PCM_16BIT
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
package com.mentra.acsmeeting.audio
|
|
2
|
+
|
|
3
|
+
import kotlin.math.min
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Clock-domain adapter in front of ACS's raw outgoing audio stream.
|
|
7
|
+
*
|
|
8
|
+
* The producer (glasses → WHEP → [PcmBridge]) runs on the glasses' audio
|
|
9
|
+
* clock; the consumer runs on the phone's monotonic clock. Those clocks never
|
|
10
|
+
* agree exactly, so a plain FIFO drained by a 20 ms timer walks to empty or to
|
|
11
|
+
* the cap over a long call even when there is no jitter at all. This class
|
|
12
|
+
* locks the buffer depth to [TARGET_MS] instead of merely bounding it:
|
|
13
|
+
*
|
|
14
|
+
* - sustained depth above [HIGH_MS] (producer fast) discards a [CORRECTION_MS] slice
|
|
15
|
+
* - sustained depth below [LOW_MS] (producer slow) inserts a [CORRECTION_MS] slice of silence
|
|
16
|
+
* - depth above [EMERGENCY_CAP_MS] drops oldest down to target immediately
|
|
17
|
+
*
|
|
18
|
+
* Corrections read an EMA over roughly two seconds and are rate limited to one
|
|
19
|
+
* per [CORRECTION_INTERVAL_MS], so ordinary jitter never triggers one; only
|
|
20
|
+
* sustained error does. A cleaner follow-up is to nudge the WHEP → ACS
|
|
21
|
+
* resampling ratio by the depth error instead of dropping and inserting
|
|
22
|
+
* slices, which removes the discontinuity entirely.
|
|
23
|
+
*
|
|
24
|
+
* [tick] returns exactly one frame per call and never a burst. The pacing
|
|
25
|
+
* deadline and the ACS I/O live in [UplinkSender]; this class is pure logic
|
|
26
|
+
* driven by an injected clock so tests can run an hour in milliseconds.
|
|
27
|
+
*
|
|
28
|
+
* [push] runs on the WebRTC audio thread and [tick] on the pacing thread, so
|
|
29
|
+
* every entry point is synchronized on this instance.
|
|
30
|
+
*/
|
|
31
|
+
class UplinkPacer(private val targetMs: Int = TARGET_MS) {
|
|
32
|
+
enum class State { PREROLLING, RUNNING, STARVED }
|
|
33
|
+
|
|
34
|
+
/** One [FRAME_MS] frame. [silence] marks a frame the pacer manufactured. */
|
|
35
|
+
class Frame(val bytes: ByteArray, val silence: Boolean)
|
|
36
|
+
|
|
37
|
+
data class Stats(
|
|
38
|
+
val state: State,
|
|
39
|
+
val depthMs: Int,
|
|
40
|
+
val targetMs: Int,
|
|
41
|
+
val sentFps: Double,
|
|
42
|
+
val framesEmitted: Long,
|
|
43
|
+
val silenceFrames: Long,
|
|
44
|
+
val overflowDroppedMs: Long,
|
|
45
|
+
val driftCorrections: Long,
|
|
46
|
+
val driftDroppedMs: Long,
|
|
47
|
+
val driftInsertedMs: Long,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
private val ring = ByteArray(CAPACITY_BYTES)
|
|
51
|
+
private var head = 0
|
|
52
|
+
private var used = 0
|
|
53
|
+
|
|
54
|
+
private var state = State.PREROLLING
|
|
55
|
+
/** Negative until seeded on the first RUNNING tick. */
|
|
56
|
+
private var depthEmaBytes = -1.0
|
|
57
|
+
/**
|
|
58
|
+
* Depth seen by the last tick's control decision, before it consumed a
|
|
59
|
+
* frame. This — not the post-pop depth a between-ticks reader would see — is
|
|
60
|
+
* the headroom a late producer actually gets, so it is what the envelope and
|
|
61
|
+
* the log are stated in.
|
|
62
|
+
*/
|
|
63
|
+
private var controlDepthBytes = 0
|
|
64
|
+
private var starvingSinceNanos: Long? = null
|
|
65
|
+
private var lastCorrectionNanos: Long? = null
|
|
66
|
+
private var windowStartNanos: Long? = null
|
|
67
|
+
private var windowFrames = 0L
|
|
68
|
+
|
|
69
|
+
private var framesEmitted = 0L
|
|
70
|
+
private var silenceFrames = 0L
|
|
71
|
+
private var overflowDroppedBytes = 0L
|
|
72
|
+
private var driftCorrections = 0L
|
|
73
|
+
private var driftDroppedBytes = 0L
|
|
74
|
+
private var driftInsertedBytes = 0L
|
|
75
|
+
|
|
76
|
+
/** Appends 48 kHz mono PCM16 from the producer thread. */
|
|
77
|
+
@Synchronized
|
|
78
|
+
fun push(pcm16Le: ByteArray) {
|
|
79
|
+
if (pcm16Le.isEmpty()) return
|
|
80
|
+
var offset = 0
|
|
81
|
+
var len = pcm16Le.size
|
|
82
|
+
if (len > CAPACITY_BYTES) {
|
|
83
|
+
// A single burst larger than the whole ring: keep only the newest audio.
|
|
84
|
+
offset = len - CAPACITY_BYTES
|
|
85
|
+
len = CAPACITY_BYTES
|
|
86
|
+
overflowDroppedBytes += offset
|
|
87
|
+
}
|
|
88
|
+
val overflow = used + len - CAPACITY_BYTES
|
|
89
|
+
if (overflow > 0) {
|
|
90
|
+
dropOldest(overflow)
|
|
91
|
+
overflowDroppedBytes += overflow
|
|
92
|
+
}
|
|
93
|
+
write(pcm16Le, offset, len)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Advances one pacing period. Returns exactly one frame — real audio when
|
|
98
|
+
* the state machine allows it, otherwise silence to keep the ACS cadence
|
|
99
|
+
* unbroken.
|
|
100
|
+
*/
|
|
101
|
+
@Synchronized
|
|
102
|
+
fun tick(nowNanos: Long): Frame {
|
|
103
|
+
if (windowStartNanos == null) windowStartNanos = nowNanos
|
|
104
|
+
enforceCap()
|
|
105
|
+
if (state == State.RUNNING) {
|
|
106
|
+
depthEmaBytes =
|
|
107
|
+
if (depthEmaBytes < 0) used.toDouble() else depthEmaBytes + EMA_ALPHA * (used - depthEmaBytes)
|
|
108
|
+
correctDrift(nowNanos)
|
|
109
|
+
}
|
|
110
|
+
controlDepthBytes = used
|
|
111
|
+
val frame = when (state) {
|
|
112
|
+
// Hold the line until there is real headroom, so the first wobble of a
|
|
113
|
+
// call does not underrun immediately.
|
|
114
|
+
State.PREROLLING -> if (depthMs() >= targetMs) enterRunning() else silence()
|
|
115
|
+
State.RUNNING -> if (used >= FRAME_BYTES) audio() else starve(nowNanos)
|
|
116
|
+
// A WHEP outage would otherwise resume with zero headroom and underrun
|
|
117
|
+
// again on the next wobble; rebuild the target first.
|
|
118
|
+
State.STARVED -> if (depthMs() >= targetMs) enterRunning() else silence()
|
|
119
|
+
}
|
|
120
|
+
framesEmitted += 1
|
|
121
|
+
windowFrames += 1
|
|
122
|
+
return frame
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Stats for the 1 Hz ladder. `sentFps` covers the window since the previous
|
|
127
|
+
* call, so exactly one caller (the sender's logger) may invoke this.
|
|
128
|
+
*/
|
|
129
|
+
@Synchronized
|
|
130
|
+
fun snapshot(nowNanos: Long): Stats {
|
|
131
|
+
val start = windowStartNanos
|
|
132
|
+
val elapsedNanos = if (start == null) 0L else nowNanos - start
|
|
133
|
+
val fps = if (elapsedNanos > 0) windowFrames * 1_000_000_000.0 / elapsedNanos else 0.0
|
|
134
|
+
windowStartNanos = nowNanos
|
|
135
|
+
windowFrames = 0
|
|
136
|
+
return Stats(
|
|
137
|
+
state = state,
|
|
138
|
+
depthMs = controlDepthBytes / BYTES_PER_MS,
|
|
139
|
+
targetMs = targetMs,
|
|
140
|
+
sentFps = fps,
|
|
141
|
+
framesEmitted = framesEmitted,
|
|
142
|
+
silenceFrames = silenceFrames,
|
|
143
|
+
overflowDroppedMs = overflowDroppedBytes / BYTES_PER_MS,
|
|
144
|
+
driftCorrections = driftCorrections,
|
|
145
|
+
driftDroppedMs = driftDroppedBytes / BYTES_PER_MS,
|
|
146
|
+
driftInsertedMs = driftInsertedBytes / BYTES_PER_MS,
|
|
147
|
+
)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
@Synchronized
|
|
151
|
+
fun reset() {
|
|
152
|
+
head = 0
|
|
153
|
+
used = 0
|
|
154
|
+
state = State.PREROLLING
|
|
155
|
+
depthEmaBytes = -1.0
|
|
156
|
+
controlDepthBytes = 0
|
|
157
|
+
starvingSinceNanos = null
|
|
158
|
+
lastCorrectionNanos = null
|
|
159
|
+
windowStartNanos = null
|
|
160
|
+
windowFrames = 0
|
|
161
|
+
framesEmitted = 0
|
|
162
|
+
silenceFrames = 0
|
|
163
|
+
overflowDroppedBytes = 0
|
|
164
|
+
driftCorrections = 0
|
|
165
|
+
driftDroppedBytes = 0
|
|
166
|
+
driftInsertedBytes = 0
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
@Synchronized
|
|
170
|
+
fun state(): State = state
|
|
171
|
+
|
|
172
|
+
/** Buffered audio right now, i.e. after the last tick consumed its frame. */
|
|
173
|
+
@Synchronized
|
|
174
|
+
fun depthMs(): Int = used / BYTES_PER_MS
|
|
175
|
+
|
|
176
|
+
/** Headroom the last tick decided on, before it consumed its frame. */
|
|
177
|
+
@Synchronized
|
|
178
|
+
fun controlDepthMs(): Int = controlDepthBytes / BYTES_PER_MS
|
|
179
|
+
|
|
180
|
+
private fun enterRunning(): Frame {
|
|
181
|
+
state = State.RUNNING
|
|
182
|
+
starvingSinceNanos = null
|
|
183
|
+
// Seed at target, not at the observed depth: preroll just guaranteed the
|
|
184
|
+
// target, and seeding from one sample would let the very first period of a
|
|
185
|
+
// stalled producer look like sustained drift.
|
|
186
|
+
depthEmaBytes = (targetMs * BYTES_PER_MS).toDouble()
|
|
187
|
+
return audio()
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
private fun audio(): Frame {
|
|
191
|
+
starvingSinceNanos = null
|
|
192
|
+
return Frame(readFrame(), silence = false)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
private fun silence(): Frame {
|
|
196
|
+
silenceFrames += 1
|
|
197
|
+
return Frame(ByteArray(FRAME_BYTES), silence = true)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
private fun starve(nowNanos: Long): Frame {
|
|
201
|
+
val since = starvingSinceNanos
|
|
202
|
+
if (since == null) {
|
|
203
|
+
starvingSinceNanos = nowNanos
|
|
204
|
+
} else if (nowNanos - since >= STARVE_MS * 1_000_000L) {
|
|
205
|
+
state = State.STARVED
|
|
206
|
+
depthEmaBytes = -1.0
|
|
207
|
+
}
|
|
208
|
+
return silence()
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
private fun enforceCap() {
|
|
212
|
+
val cap = EMERGENCY_CAP_MS * BYTES_PER_MS
|
|
213
|
+
if (used <= cap) return
|
|
214
|
+
val excess = used - targetMs * BYTES_PER_MS
|
|
215
|
+
dropOldest(excess)
|
|
216
|
+
overflowDroppedBytes += excess
|
|
217
|
+
depthEmaBytes = used.toDouble()
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
private fun correctDrift(nowNanos: Long) {
|
|
221
|
+
if (depthEmaBytes < 0) return
|
|
222
|
+
val last = lastCorrectionNanos
|
|
223
|
+
if (last != null && nowNanos - last < CORRECTION_INTERVAL_MS * 1_000_000L) return
|
|
224
|
+
val emaMs = depthEmaBytes / BYTES_PER_MS
|
|
225
|
+
val slice = CORRECTION_MS * BYTES_PER_MS
|
|
226
|
+
when {
|
|
227
|
+
emaMs > HIGH_MS -> {
|
|
228
|
+
dropOldest(slice)
|
|
229
|
+
driftDroppedBytes += slice
|
|
230
|
+
}
|
|
231
|
+
emaMs < LOW_MS -> {
|
|
232
|
+
if (used + slice > CAPACITY_BYTES) return
|
|
233
|
+
write(SILENCE_SLICE, 0, slice)
|
|
234
|
+
driftInsertedBytes += slice
|
|
235
|
+
}
|
|
236
|
+
else -> return
|
|
237
|
+
}
|
|
238
|
+
driftCorrections += 1
|
|
239
|
+
lastCorrectionNanos = nowNanos
|
|
240
|
+
depthEmaBytes = used.toDouble()
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
private fun dropOldest(bytes: Int) {
|
|
244
|
+
val n = min(bytes, used)
|
|
245
|
+
head = (head + n) % CAPACITY_BYTES
|
|
246
|
+
used -= n
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
private fun write(src: ByteArray, srcOffset: Int, len: Int) {
|
|
250
|
+
var tail = (head + used) % CAPACITY_BYTES
|
|
251
|
+
var from = srcOffset
|
|
252
|
+
var remaining = len
|
|
253
|
+
while (remaining > 0) {
|
|
254
|
+
val chunk = min(remaining, CAPACITY_BYTES - tail)
|
|
255
|
+
System.arraycopy(src, from, ring, tail, chunk)
|
|
256
|
+
tail = (tail + chunk) % CAPACITY_BYTES
|
|
257
|
+
from += chunk
|
|
258
|
+
remaining -= chunk
|
|
259
|
+
}
|
|
260
|
+
used += len
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
private fun readFrame(): ByteArray {
|
|
264
|
+
val out = ByteArray(FRAME_BYTES)
|
|
265
|
+
var from = head
|
|
266
|
+
var to = 0
|
|
267
|
+
var remaining = FRAME_BYTES
|
|
268
|
+
while (remaining > 0) {
|
|
269
|
+
val chunk = min(remaining, CAPACITY_BYTES - from)
|
|
270
|
+
System.arraycopy(ring, from, out, to, chunk)
|
|
271
|
+
from = (from + chunk) % CAPACITY_BYTES
|
|
272
|
+
to += chunk
|
|
273
|
+
remaining -= chunk
|
|
274
|
+
}
|
|
275
|
+
head = from
|
|
276
|
+
used -= FRAME_BYTES
|
|
277
|
+
return out
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
companion object {
|
|
281
|
+
const val RATE = 48_000
|
|
282
|
+
const val CHANNELS = 1
|
|
283
|
+
const val FRAME_MS = 20
|
|
284
|
+
const val BYTES_PER_MS = RATE * CHANNELS * 2 / 1000
|
|
285
|
+
const val FRAME_BYTES = FRAME_MS * BYTES_PER_MS
|
|
286
|
+
|
|
287
|
+
const val TARGET_MS = 60
|
|
288
|
+
const val LOW_MS = 40
|
|
289
|
+
const val HIGH_MS = 100
|
|
290
|
+
const val EMERGENCY_CAP_MS = 200
|
|
291
|
+
|
|
292
|
+
const val CORRECTION_MS = 10
|
|
293
|
+
const val CORRECTION_INTERVAL_MS = 500L
|
|
294
|
+
const val STARVE_MS = 200L
|
|
295
|
+
|
|
296
|
+
private const val CAPACITY_MS = 2_000
|
|
297
|
+
private const val CAPACITY_BYTES = CAPACITY_MS * BYTES_PER_MS
|
|
298
|
+
/** ~2 s at 50 ticks/s: long enough that jitter does not move it. */
|
|
299
|
+
private const val EMA_ALPHA = 0.02
|
|
300
|
+
private val SILENCE_SLICE = ByteArray(CORRECTION_MS * BYTES_PER_MS)
|
|
301
|
+
}
|
|
302
|
+
}
|