@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.
Files changed (102) hide show
  1. package/NOTICE +20 -0
  2. package/README.md +412 -0
  3. package/THIRD_PARTY +7 -0
  4. package/android/build.gradle +64 -0
  5. package/android/src/main/AndroidManifest.xml +5 -0
  6. package/android/src/main/java/com/mentra/acsmeeting/AcsMeetingModule.kt +80 -0
  7. package/android/src/main/java/com/mentra/acsmeeting/AcsMeetingSession.kt +846 -0
  8. package/android/src/main/java/com/mentra/acsmeeting/RemoteRoster.kt +123 -0
  9. package/android/src/main/java/com/mentra/acsmeeting/audio/AcsAudioPolicy.kt +90 -0
  10. package/android/src/main/java/com/mentra/acsmeeting/audio/AudioPolicyApplier.kt +176 -0
  11. package/android/src/main/java/com/mentra/acsmeeting/audio/IncomingAudioPump.kt +112 -0
  12. package/android/src/main/java/com/mentra/acsmeeting/audio/IncomingRateProbe.kt +65 -0
  13. package/android/src/main/java/com/mentra/acsmeeting/audio/PcmBridge.kt +108 -0
  14. package/android/src/main/java/com/mentra/acsmeeting/audio/PcmResampler.kt +159 -0
  15. package/android/src/main/java/com/mentra/acsmeeting/audio/PhoneMicCapturer.kt +115 -0
  16. package/android/src/main/java/com/mentra/acsmeeting/audio/UplinkPacer.kt +302 -0
  17. package/android/src/main/java/com/mentra/acsmeeting/audio/UplinkSender.kt +230 -0
  18. package/android/src/main/java/com/mentra/acsmeeting/source/BitmapFont.kt +99 -0
  19. package/android/src/main/java/com/mentra/acsmeeting/source/CloudflareWhepSource.kt +578 -0
  20. package/android/src/main/java/com/mentra/acsmeeting/source/FirstFrameGate.kt +45 -0
  21. package/android/src/main/java/com/mentra/acsmeeting/source/GlassesMediaSource.kt +101 -0
  22. package/android/src/main/java/com/mentra/acsmeeting/source/MediaListeners.kt +57 -0
  23. package/android/src/main/java/com/mentra/acsmeeting/source/SyntheticFrameFactory.kt +205 -0
  24. package/android/src/main/java/com/mentra/acsmeeting/source/SyntheticI420Source.kt +214 -0
  25. package/android/src/main/java/com/mentra/acsmeeting/source/TrackRegistry.kt +31 -0
  26. package/android/src/main/java/com/mentra/acsmeeting/source/VideoSourceArm.kt +41 -0
  27. package/android/src/main/java/com/mentra/acsmeeting/telemetry/ChromaProbe.kt +78 -0
  28. package/android/src/main/java/com/mentra/acsmeeting/telemetry/PipelineStats.kt +281 -0
  29. package/android/src/main/java/com/mentra/acsmeeting/telemetry/PipelineTicker.kt +51 -0
  30. package/android/src/main/java/com/mentra/acsmeeting/telemetry/ProcessCpu.kt +15 -0
  31. package/android/src/main/java/com/mentra/acsmeeting/telemetry/RingPercentile.kt +33 -0
  32. package/android/src/main/java/com/mentra/acsmeeting/video/AcsFrameSender.kt +475 -0
  33. package/android/src/main/java/com/mentra/acsmeeting/video/AcsTimestamp.kt +26 -0
  34. package/android/src/main/java/com/mentra/acsmeeting/video/FrameGeometry.kt +22 -0
  35. package/android/src/main/java/com/mentra/acsmeeting/video/I420FormatSpec.kt +90 -0
  36. package/android/src/main/java/com/mentra/acsmeeting/video/I420Packer.kt +72 -0
  37. package/android/src/main/java/com/mentra/acsmeeting/video/Nv12FormatSpec.kt +36 -0
  38. package/android/src/main/java/com/mentra/acsmeeting/video/Nv12Packer.kt +51 -0
  39. package/android/src/main/java/com/mentra/acsmeeting/video/SendGate.kt +27 -0
  40. package/android/src/main/java/com/mentra/acsmeeting/video/VideoProfile.kt +57 -0
  41. package/android/src/test/java/com/mentra/acsmeeting/RemoteRosterTest.kt +40 -0
  42. package/android/src/test/java/com/mentra/acsmeeting/audio/AcsAudioPolicyTest.kt +204 -0
  43. package/android/src/test/java/com/mentra/acsmeeting/audio/AudioPolicyApplierTest.kt +223 -0
  44. package/android/src/test/java/com/mentra/acsmeeting/audio/CapturePolicyTest.kt +12 -0
  45. package/android/src/test/java/com/mentra/acsmeeting/audio/IncomingAudioPumpTest.kt +97 -0
  46. package/android/src/test/java/com/mentra/acsmeeting/audio/IncomingRateProbeTest.kt +78 -0
  47. package/android/src/test/java/com/mentra/acsmeeting/audio/PcmResamplerTest.kt +125 -0
  48. package/android/src/test/java/com/mentra/acsmeeting/audio/UplinkPacerTest.kt +201 -0
  49. package/android/src/test/java/com/mentra/acsmeeting/audio/UplinkSenderTest.kt +166 -0
  50. package/android/src/test/java/com/mentra/acsmeeting/source/AcsInvestigationTest.kt +26 -0
  51. package/android/src/test/java/com/mentra/acsmeeting/source/BitmapFontTest.kt +22 -0
  52. package/android/src/test/java/com/mentra/acsmeeting/source/FirstFrameGateTest.kt +60 -0
  53. package/android/src/test/java/com/mentra/acsmeeting/source/GlassesMediaSourceTest.kt +133 -0
  54. package/android/src/test/java/com/mentra/acsmeeting/source/I420PlanesTest.kt +67 -0
  55. package/android/src/test/java/com/mentra/acsmeeting/source/SyntheticFrameFactoryTest.kt +193 -0
  56. package/android/src/test/java/com/mentra/acsmeeting/source/SyntheticI420SourceTest.kt +176 -0
  57. package/android/src/test/java/com/mentra/acsmeeting/source/TrackRegistryTest.kt +36 -0
  58. package/android/src/test/java/com/mentra/acsmeeting/telemetry/ChromaProbeTest.kt +61 -0
  59. package/android/src/test/java/com/mentra/acsmeeting/telemetry/PipelineStatsTest.kt +256 -0
  60. package/android/src/test/java/com/mentra/acsmeeting/telemetry/ProcessCpuTest.kt +18 -0
  61. package/android/src/test/java/com/mentra/acsmeeting/telemetry/RingPercentileTest.kt +49 -0
  62. package/android/src/test/java/com/mentra/acsmeeting/video/AcsTimestampTest.kt +43 -0
  63. package/android/src/test/java/com/mentra/acsmeeting/video/FrameGeometryTest.kt +33 -0
  64. package/android/src/test/java/com/mentra/acsmeeting/video/I420FormatSpecTest.kt +77 -0
  65. package/android/src/test/java/com/mentra/acsmeeting/video/I420PackerTest.kt +126 -0
  66. package/android/src/test/java/com/mentra/acsmeeting/video/Nv12FormatSpecTest.kt +36 -0
  67. package/android/src/test/java/com/mentra/acsmeeting/video/Nv12PackerTest.kt +58 -0
  68. package/android/src/test/java/com/mentra/acsmeeting/video/SendGateTest.kt +79 -0
  69. package/android/src/test/java/com/mentra/acsmeeting/video/VideoProfileTest.kt +68 -0
  70. package/build/AcsMeeting.types.d.ts +59 -0
  71. package/build/AcsMeeting.types.d.ts.map +1 -0
  72. package/build/AcsMeeting.types.js +2 -0
  73. package/build/AcsMeeting.types.js.map +1 -0
  74. package/build/AcsMeetingModule.d.ts +15 -0
  75. package/build/AcsMeetingModule.d.ts.map +1 -0
  76. package/build/AcsMeetingModule.js +3 -0
  77. package/build/AcsMeetingModule.js.map +1 -0
  78. package/build/AcsMeetingModule.web.d.ts +16 -0
  79. package/build/AcsMeetingModule.web.d.ts.map +1 -0
  80. package/build/AcsMeetingModule.web.js +31 -0
  81. package/build/AcsMeetingModule.web.js.map +1 -0
  82. package/build/index.d.ts +3 -0
  83. package/build/index.d.ts.map +1 -0
  84. package/build/index.js +3 -0
  85. package/build/index.js.map +1 -0
  86. package/expo-module.config.json +9 -0
  87. package/ios/AcsFrameSender.swift +47 -0
  88. package/ios/AcsMeeting.podspec +27 -0
  89. package/ios/AcsMeetingModule.swift +558 -0
  90. package/ios/PcmBridge.swift +117 -0
  91. package/ios/PhoneMicCapturer.swift +64 -0
  92. package/ios/PolicyKit/Package.swift +17 -0
  93. package/ios/PolicyKit/Sources/AcsAudioPolicy/AcsAudioPolicy.swift +102 -0
  94. package/ios/PolicyKit/Sources/AcsAudioPolicy/AudioPolicyApplier.swift +162 -0
  95. package/ios/PolicyKit/Sources/AcsAudioPolicy/GlassesMediaSource.swift +68 -0
  96. package/ios/PolicyKit/Tests/AcsAudioPolicyTests/AcsAudioPolicyTests.swift +273 -0
  97. package/ios/WhepVideoSource.swift +356 -0
  98. package/package.json +56 -0
  99. package/src/AcsMeeting.types.ts +66 -0
  100. package/src/AcsMeetingModule.ts +16 -0
  101. package/src/AcsMeetingModule.web.ts +33 -0
  102. 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
+ }