@parastud/react-native-vban 0.1.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 ADDED
@@ -0,0 +1,87 @@
1
+ # @parastud/react-native-vban
2
+
3
+ VBAN (VB-Audio Network) audio-over-IP for **React Native / Expo**. Stream audio
4
+ to and from a PC running **Voicemeeter** (or any VBAN endpoint) over your local
5
+ network — no server, just UDP.
6
+
7
+ - **Native receive** — a UDP socket + `AudioTrack` playback run entirely on a
8
+ native thread (with a large socket buffer), so you don't drop packets waiting
9
+ on the JS bridge.
10
+ - **Native send** — `AudioRecord` capture → VBAN packetization → UDP, also fully
11
+ native and paced by the audio clock, so packets are evenly spaced.
12
+ - **Device (system) audio capture** — capture the phone's own playback via
13
+ Android `MediaProjection` + `AudioPlaybackCapture` (Android 10+).
14
+ - **Pure-TypeScript VBAN codec** — encode/decode packet headers and convert PCM,
15
+ usable on its own.
16
+
17
+ > **Platform:** Android only for now. 16-bit PCM audio streams.
18
+
19
+ ## Install
20
+
21
+ ```sh
22
+ npm install @parastud/react-native-vban
23
+ ```
24
+
25
+ This is an [Expo module](https://docs.expo.dev/modules/) — it autolinks. You need
26
+ a **development build** (not Expo Go). After installing, rebuild the native app:
27
+
28
+ ```sh
29
+ npx expo run:android
30
+ ```
31
+
32
+ The module adds the `FOREGROUND_SERVICE_MEDIA_PROJECTION` permission and a
33
+ `mediaProjection` foreground service. Make sure your app also declares
34
+ `RECORD_AUDIO` and `INTERNET`.
35
+
36
+ ## Usage
37
+
38
+ ### Receive a stream (play it on the phone)
39
+
40
+ ```ts
41
+ import { NativeVbanReceiver } from '@parastud/react-native-vban';
42
+
43
+ const rx = new NativeVbanReceiver();
44
+ await rx.start({ port: 6980 }, (stats) => {
45
+ console.log(stats.streamName, stats.level, 'dropped', stats.dropped);
46
+ });
47
+ // ... later
48
+ await rx.stop();
49
+ ```
50
+
51
+ Point Voicemeeter's outgoing VBAN stream at the phone's IP on that port.
52
+
53
+ ### Send audio to the PC
54
+
55
+ ```ts
56
+ import { NativeVbanSender, isDeviceCaptureSupported } from '@parastud/react-native-vban';
57
+
58
+ const tx = new NativeVbanSender();
59
+ await tx.start(
60
+ {
61
+ host: '192.168.0.119', // the PC's IP
62
+ port: 6980,
63
+ streamName: 'Stream1',
64
+ sampleRate: 48000,
65
+ channels: 2,
66
+ source: 'mic', // or 'device' for system audio (shows a capture consent dialog)
67
+ },
68
+ (level) => console.log('level', level),
69
+ );
70
+ // ... later
71
+ await tx.stop();
72
+ ```
73
+
74
+ `source: 'device'` requires Android's MediaProjection consent (the OS
75
+ "start recording/casting" dialog). Only audio is captured — nothing of the
76
+ screen is read or sent — but the consent prompt is mandatory and cannot be
77
+ suppressed.
78
+
79
+ ### Pure VBAN codec
80
+
81
+ ```ts
82
+ import { encodePacket, decodeHeader, float32ToPcm, VbanDataType } from '@parastud/react-native-vban';
83
+ ```
84
+
85
+ ## License
86
+
87
+ MIT
@@ -0,0 +1,15 @@
1
+ plugins {
2
+ id 'com.android.library'
3
+ id 'expo-module-gradle-plugin'
4
+ }
5
+
6
+ group = 'expo.modules.systemaudiocapture'
7
+ version = '0.0.1'
8
+
9
+ android {
10
+ namespace "expo.modules.systemaudiocapture"
11
+ defaultConfig {
12
+ versionCode 1
13
+ versionName '0.0.1'
14
+ }
15
+ }
@@ -0,0 +1,12 @@
1
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android">
2
+
3
+ <!-- Required (API 34+) to run a mediaProjection foreground service; ignored on older versions. -->
4
+ <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
5
+
6
+ <application>
7
+ <service
8
+ android:name=".SystemAudioCaptureService"
9
+ android:exported="false"
10
+ android:foregroundServiceType="mediaProjection" />
11
+ </application>
12
+ </manifest>
@@ -0,0 +1,57 @@
1
+ package expo.modules.systemaudiocapture
2
+
3
+ import android.app.Activity
4
+ import android.content.Context
5
+ import android.media.projection.MediaProjectionManager
6
+ import android.os.Build
7
+ import expo.modules.kotlin.Promise
8
+ import expo.modules.kotlin.modules.Module
9
+ import expo.modules.kotlin.modules.ModuleDefinition
10
+
11
+ /**
12
+ * Handles the MediaProjection consent flow for capturing device (playback)
13
+ * audio. The granted projection token is stashed on
14
+ * [SystemAudioCaptureService], which VbanSenderModule then uses to start a
15
+ * native device-audio VBAN send. This module does no capturing itself.
16
+ */
17
+ class SystemAudioCaptureModule : Module() {
18
+ private val requestCode = 0xB1D0
19
+ private var permissionPromise: Promise? = null
20
+
21
+ override fun definition() = ModuleDefinition {
22
+ Name("SystemAudioCapture")
23
+
24
+ Function("isSupported") {
25
+ Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
26
+ }
27
+
28
+ AsyncFunction("requestPermission") { promise: Promise ->
29
+ val activity = appContext.currentActivity
30
+ if (activity == null) {
31
+ promise.reject("NO_ACTIVITY", "No current activity to request permission from", null)
32
+ return@AsyncFunction
33
+ }
34
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
35
+ promise.reject("UNSUPPORTED", "System audio capture requires Android 10+", null)
36
+ return@AsyncFunction
37
+ }
38
+ permissionPromise = promise
39
+ val mpm = activity.getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
40
+ activity.startActivityForResult(mpm.createScreenCaptureIntent(), requestCode)
41
+ }
42
+
43
+ OnActivityResult { _, payload ->
44
+ if (payload.requestCode == requestCode) {
45
+ val promise = permissionPromise
46
+ permissionPromise = null
47
+ if (payload.resultCode == Activity.RESULT_OK && payload.data != null) {
48
+ SystemAudioCaptureService.resultCode = payload.resultCode
49
+ SystemAudioCaptureService.resultData = payload.data
50
+ promise?.resolve(true)
51
+ } else {
52
+ promise?.resolve(false)
53
+ }
54
+ }
55
+ }
56
+ }
57
+ }
@@ -0,0 +1,151 @@
1
+ package expo.modules.systemaudiocapture
2
+
3
+ import android.app.Notification
4
+ import android.app.NotificationChannel
5
+ import android.app.NotificationManager
6
+ import android.app.Service
7
+ import android.content.Context
8
+ import android.content.Intent
9
+ import android.content.pm.ServiceInfo
10
+ import android.media.AudioAttributes
11
+ import android.media.AudioFormat
12
+ import android.media.AudioPlaybackCaptureConfiguration
13
+ import android.media.AudioRecord
14
+ import android.media.projection.MediaProjection
15
+ import android.media.projection.MediaProjectionManager
16
+ import android.os.Build
17
+ import android.os.IBinder
18
+
19
+ /**
20
+ * Foreground service that captures the device's playback audio via
21
+ * MediaProjection + AudioPlaybackCapture (Android 10+) and streams it out as
22
+ * VBAN through a [VbanSendEngine] — entirely in native code. A foreground
23
+ * service of type `mediaProjection` must be running for the projection to stay
24
+ * valid, which is why the capture + send live here.
25
+ */
26
+ class SystemAudioCaptureService : Service() {
27
+ private var mediaProjection: MediaProjection? = null
28
+ private var engine: VbanSendEngine? = null
29
+
30
+ companion object {
31
+ const val ACTION_START = "expo.modules.systemaudiocapture.START"
32
+ const val ACTION_STOP = "expo.modules.systemaudiocapture.STOP"
33
+
34
+ // MediaProjection consent result (set by SystemAudioCaptureModule).
35
+ var resultCode: Int = 0
36
+ var resultData: Intent? = null
37
+
38
+ // VBAN send config (set by VbanSenderModule before starting).
39
+ var host: String = ""
40
+ var port: Int = 6980
41
+ var streamName: String = "Stream1"
42
+ var sampleRate: Int = 48000
43
+ var channels: Int = 2
44
+
45
+ // Callbacks into the module while a session is active.
46
+ var onStats: ((Float, Long) -> Unit)? = null
47
+ var onErr: ((String) -> Unit)? = null
48
+
49
+ private const val CHANNEL_ID = "vban_system_audio"
50
+ private const val NOTIF_ID = 8123
51
+ }
52
+
53
+ override fun onBind(intent: Intent?): IBinder? = null
54
+
55
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
56
+ if (intent?.action == ACTION_STOP) {
57
+ stopCapture()
58
+ stopSelf()
59
+ return START_NOT_STICKY
60
+ }
61
+ startForegroundNotification()
62
+ try {
63
+ startCapture()
64
+ } catch (e: Exception) {
65
+ onErr?.invoke(e.message ?: "System audio capture failed to start")
66
+ stopCapture()
67
+ stopSelf()
68
+ }
69
+ return START_NOT_STICKY
70
+ }
71
+
72
+ private fun startForegroundNotification() {
73
+ val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
74
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
75
+ nm.createNotificationChannel(
76
+ NotificationChannel(CHANNEL_ID, "VBAN audio capture", NotificationManager.IMPORTANCE_LOW)
77
+ )
78
+ }
79
+ val notification: Notification = Notification.Builder(this, CHANNEL_ID)
80
+ .setContentTitle("VBAN")
81
+ .setContentText("Streaming device audio over the network")
82
+ .setSmallIcon(android.R.drawable.ic_btn_speak_now)
83
+ .build()
84
+
85
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
86
+ startForeground(NOTIF_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION)
87
+ } else {
88
+ startForeground(NOTIF_ID, notification)
89
+ }
90
+ }
91
+
92
+ private fun startCapture() {
93
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
94
+ throw IllegalStateException("System audio capture requires Android 10 or newer")
95
+ }
96
+ val data = resultData ?: throw IllegalStateException("No screen-capture permission granted")
97
+
98
+ val mpm = getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
99
+ val projection = mpm.getMediaProjection(resultCode, data)
100
+ ?: throw IllegalStateException("Could not obtain MediaProjection")
101
+ projection.registerCallback(object : MediaProjection.Callback() {
102
+ override fun onStop() {
103
+ stopCapture()
104
+ stopSelf()
105
+ }
106
+ }, null)
107
+ mediaProjection = projection
108
+
109
+ val config = AudioPlaybackCaptureConfiguration.Builder(projection)
110
+ .addMatchingUsage(AudioAttributes.USAGE_MEDIA)
111
+ .addMatchingUsage(AudioAttributes.USAGE_GAME)
112
+ .addMatchingUsage(AudioAttributes.USAGE_UNKNOWN)
113
+ .build()
114
+
115
+ val channelMask =
116
+ if (channels == 1) AudioFormat.CHANNEL_IN_MONO else AudioFormat.CHANNEL_IN_STEREO
117
+ val format = AudioFormat.Builder()
118
+ .setEncoding(AudioFormat.ENCODING_PCM_16BIT)
119
+ .setSampleRate(sampleRate)
120
+ .setChannelMask(channelMask)
121
+ .build()
122
+
123
+ val minBuf = AudioRecord.getMinBufferSize(sampleRate, channelMask, AudioFormat.ENCODING_PCM_16BIT)
124
+ val bufSize = if (minBuf > 0) minBuf * 2 else sampleRate * channels
125
+
126
+ val record = AudioRecord.Builder()
127
+ .setAudioFormat(format)
128
+ .setBufferSizeInBytes(bufSize)
129
+ .setAudioPlaybackCaptureConfig(config)
130
+ .build()
131
+
132
+ engine = VbanSendEngine(
133
+ record, host, port, streamName, sampleRate, channels,
134
+ onStats = { level, packets -> onStats?.invoke(level, packets) },
135
+ onError = { msg -> onErr?.invoke(msg) },
136
+ )
137
+ engine!!.start()
138
+ }
139
+
140
+ private fun stopCapture() {
141
+ engine?.stop()
142
+ engine = null
143
+ try { mediaProjection?.stop() } catch (_: Exception) {}
144
+ mediaProjection = null
145
+ }
146
+
147
+ override fun onDestroy() {
148
+ stopCapture()
149
+ super.onDestroy()
150
+ }
151
+ }
@@ -0,0 +1,216 @@
1
+ package expo.modules.systemaudiocapture
2
+
3
+ import android.media.AudioAttributes
4
+ import android.media.AudioFormat
5
+ import android.media.AudioTrack
6
+ import expo.modules.kotlin.Promise
7
+ import expo.modules.kotlin.modules.Module
8
+ import expo.modules.kotlin.modules.ModuleDefinition
9
+ import java.net.DatagramPacket
10
+ import java.net.DatagramSocket
11
+ import java.net.InetSocketAddress
12
+
13
+ /**
14
+ * Native VBAN receiver: binds a UDP socket (with a large receive buffer), parses
15
+ * VBAN audio packets, and writes 16-bit PCM straight to an AudioTrack — all on a
16
+ * background thread. This keeps the whole hot path in native code so we never
17
+ * lose packets to a slow JS bridge. Only throttled stats reach JS for the meter.
18
+ *
19
+ * The blocking AudioTrack write paces the loop: audio arrives and plays at the
20
+ * same sample rate, so the socket's buffer only has to absorb network jitter.
21
+ */
22
+ class VbanReceiverModule : Module() {
23
+ @Volatile private var running = false
24
+ private var thread: Thread? = null
25
+ private var socket: DatagramSocket? = null
26
+
27
+ companion object {
28
+ private val SR = intArrayOf(
29
+ 6000, 12000, 24000, 48000, 96000, 192000, 384000,
30
+ 8000, 16000, 32000, 64000, 128000, 256000, 512000,
31
+ 11025, 22050, 44100, 88200, 176400, 352800, 705600,
32
+ )
33
+ }
34
+
35
+ override fun definition() = ModuleDefinition {
36
+ Name("VbanNativeReceiver")
37
+ Events("onStats", "onError")
38
+
39
+ AsyncFunction("start") { port: Int, filter: String?, promise: Promise ->
40
+ if (running) {
41
+ promise.resolve(null)
42
+ return@AsyncFunction
43
+ }
44
+ try {
45
+ startReceive(port, filter)
46
+ promise.resolve(null)
47
+ } catch (e: Exception) {
48
+ promise.reject("START_FAILED", e.message, e)
49
+ }
50
+ }
51
+
52
+ AsyncFunction("stop") { promise: Promise ->
53
+ stopReceive()
54
+ promise.resolve(null)
55
+ }
56
+
57
+ OnDestroy { stopReceive() }
58
+ }
59
+
60
+ private fun startReceive(port: Int, filter: String?) {
61
+ val sock = DatagramSocket(null)
62
+ sock.reuseAddress = true
63
+ try { sock.receiveBufferSize = 4 * 1024 * 1024 } catch (_: Exception) {}
64
+ sock.bind(InetSocketAddress(port))
65
+ socket = sock
66
+ running = true
67
+
68
+ thread = Thread {
69
+ val buf = ByteArray(2048)
70
+ val packet = DatagramPacket(buf, buf.size)
71
+ var track: AudioTrack? = null
72
+ var trackSr = 0
73
+ var trackCh = 0
74
+ var lastFrame = -1L
75
+ var dropped = 0L
76
+ var packets = 0L
77
+ var peak = 0f
78
+ var lastEmit = System.currentTimeMillis()
79
+ var streamName = ""
80
+ var address = ""
81
+
82
+ try {
83
+ while (running) {
84
+ packet.length = buf.size
85
+ try {
86
+ sock.receive(packet)
87
+ } catch (e: Exception) {
88
+ if (running) continue else break
89
+ }
90
+ val len = packet.length
91
+ if (len < 28) continue
92
+
93
+ // 'VBAN' magic
94
+ if (buf[0].toInt() != 0x56 || buf[1].toInt() != 0x42 ||
95
+ buf[2].toInt() != 0x41 || buf[3].toInt() != 0x4E
96
+ ) continue
97
+
98
+ val formatSR = buf[4].toInt() and 0xFF
99
+ if ((formatSR and 0xE0) != 0x00) continue // audio sub-protocol only
100
+ val srIndex = formatSR and 0x1F
101
+ if (srIndex >= SR.size) continue
102
+ val sampleRate = SR[srIndex]
103
+ val samplesPerFrame = (buf[5].toInt() and 0xFF) + 1
104
+ val channels = (buf[6].toInt() and 0xFF) + 1
105
+ val formatBit = buf[7].toInt() and 0xFF
106
+ if ((formatBit and 0xF0) != 0x00) continue // PCM codec only
107
+ if ((formatBit and 0x07) != 0x01) continue // 16-bit PCM only
108
+
109
+ val sb = StringBuilder()
110
+ for (i in 8 until 24) {
111
+ val c = buf[i].toInt() and 0xFF
112
+ if (c == 0) break
113
+ sb.append(c.toChar())
114
+ }
115
+ val name = sb.toString()
116
+ if (!filter.isNullOrEmpty() && name != filter) continue
117
+
118
+ val frameCount = (buf[24].toInt() and 0xFF).toLong() or
119
+ ((buf[25].toInt() and 0xFF).toLong() shl 8) or
120
+ ((buf[26].toInt() and 0xFF).toLong() shl 16) or
121
+ ((buf[27].toInt() and 0xFF).toLong() shl 24)
122
+
123
+ val payloadBytes = samplesPerFrame * channels * 2
124
+ if (28 + payloadBytes > len) continue
125
+
126
+ if (track == null || trackSr != sampleRate || trackCh != channels) {
127
+ track?.let { try { it.stop(); it.release() } catch (_: Exception) {} }
128
+ val channelMask =
129
+ if (channels == 1) AudioFormat.CHANNEL_OUT_MONO else AudioFormat.CHANNEL_OUT_STEREO
130
+ val minBuf = AudioTrack.getMinBufferSize(
131
+ sampleRate, channelMask, AudioFormat.ENCODING_PCM_16BIT
132
+ )
133
+ val bufSize = if (minBuf > 0) minBuf * 4 else sampleRate * channels
134
+ track = AudioTrack.Builder()
135
+ .setAudioAttributes(
136
+ AudioAttributes.Builder()
137
+ .setUsage(AudioAttributes.USAGE_MEDIA)
138
+ .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
139
+ .build()
140
+ )
141
+ .setAudioFormat(
142
+ AudioFormat.Builder()
143
+ .setEncoding(AudioFormat.ENCODING_PCM_16BIT)
144
+ .setSampleRate(sampleRate)
145
+ .setChannelMask(channelMask)
146
+ .build()
147
+ )
148
+ .setBufferSizeInBytes(bufSize)
149
+ .setTransferMode(AudioTrack.MODE_STREAM)
150
+ .build()
151
+ track!!.play()
152
+ trackSr = sampleRate
153
+ trackCh = channels
154
+ lastFrame = -1L
155
+ }
156
+
157
+ if (lastFrame >= 0) {
158
+ val gap = (frameCount - lastFrame - 1) and 0xFFFFFFFFL
159
+ if (gap in 1..999) dropped += gap
160
+ }
161
+ lastFrame = frameCount
162
+ packets++
163
+ streamName = name
164
+ address = packet.address?.hostAddress ?: ""
165
+
166
+ // Peak level over the packet's samples for the meter.
167
+ var i = 28
168
+ val end = 28 + payloadBytes
169
+ var localPeak = 0
170
+ while (i + 1 < end) {
171
+ val raw = (buf[i].toInt() and 0xFF) or (buf[i + 1].toInt() shl 8)
172
+ val v = if (raw > 32767) raw - 65536 else raw
173
+ val a = if (v < 0) -v else v
174
+ if (a > localPeak) localPeak = a
175
+ i += 2
176
+ }
177
+ val pk = localPeak / 32768f
178
+ if (pk > peak) peak = pk
179
+
180
+ // Blocking write — paces the loop to real time.
181
+ track!!.write(buf, 28, payloadBytes)
182
+
183
+ val now = System.currentTimeMillis()
184
+ if (now - lastEmit >= 100) {
185
+ sendEvent(
186
+ "onStats",
187
+ mapOf(
188
+ "streamName" to streamName,
189
+ "address" to address,
190
+ "sampleRate" to trackSr,
191
+ "channels" to trackCh,
192
+ "level" to peak,
193
+ "dropped" to dropped,
194
+ "packets" to packets,
195
+ ),
196
+ )
197
+ lastEmit = now
198
+ peak = 0f
199
+ }
200
+ }
201
+ } catch (e: Exception) {
202
+ sendEvent("onError", mapOf("message" to (e.message ?: "receive error")))
203
+ } finally {
204
+ track?.let { try { it.stop(); it.release() } catch (_: Exception) {} }
205
+ }
206
+ }.also { it.start() }
207
+ }
208
+
209
+ private fun stopReceive() {
210
+ running = false
211
+ try { socket?.close() } catch (_: Exception) {}
212
+ socket = null
213
+ try { thread?.join(500) } catch (_: Exception) {}
214
+ thread = null
215
+ }
216
+ }
@@ -0,0 +1,141 @@
1
+ package expo.modules.systemaudiocapture
2
+
3
+ import android.media.AudioRecord
4
+ import java.net.DatagramPacket
5
+ import java.net.DatagramSocket
6
+ import java.net.InetAddress
7
+
8
+ /**
9
+ * Reads 16-bit PCM from an already-configured AudioRecord, packetizes it into
10
+ * VBAN datagrams (256 frames/packet), and sends them to a host/port — all on a
11
+ * single background thread. The blocking AudioRecord.read paces the loop to real
12
+ * time, so packets go out evenly spaced (no bursts for the receiver to choke on).
13
+ *
14
+ * Shared by the mic sender (module) and the device-audio sender (service).
15
+ */
16
+ class VbanSendEngine(
17
+ private val record: AudioRecord,
18
+ private val host: String,
19
+ private val port: Int,
20
+ private val streamName: String,
21
+ private val sampleRate: Int,
22
+ private val channels: Int,
23
+ private val onStats: (level: Float, packets: Long) -> Unit,
24
+ private val onError: (String) -> Unit,
25
+ ) {
26
+ @Volatile private var running = false
27
+ private var thread: Thread? = null
28
+
29
+ companion object {
30
+ private val SR = intArrayOf(
31
+ 6000, 12000, 24000, 48000, 96000, 192000, 384000,
32
+ 8000, 16000, 32000, 64000, 128000, 256000, 512000,
33
+ 11025, 22050, 44100, 88200, 176400, 352800, 705600,
34
+ )
35
+
36
+ fun srIndex(sr: Int): Int {
37
+ for (i in SR.indices) if (SR[i] == sr) return i
38
+ return -1
39
+ }
40
+ }
41
+
42
+ fun start() {
43
+ if (running) return
44
+ val srIdx = srIndex(sampleRate)
45
+ if (srIdx < 0) {
46
+ onError("Unsupported VBAN sample rate: $sampleRate")
47
+ return
48
+ }
49
+
50
+ val socket = DatagramSocket()
51
+ val addr = InetAddress.getByName(host)
52
+ running = true
53
+ try {
54
+ record.startRecording()
55
+ } catch (e: Exception) {
56
+ onError("Failed to start capture: ${e.message}")
57
+ running = false
58
+ return
59
+ }
60
+
61
+ thread = Thread {
62
+ val framesPerPacket = 256
63
+ val shortBuf = ShortArray(framesPerPacket * channels)
64
+ val packet = ByteArray(28 + framesPerPacket * channels * 2)
65
+
66
+ // Static header fields.
67
+ packet[0] = 0x56; packet[1] = 0x42; packet[2] = 0x41; packet[3] = 0x4E // 'VBAN'
68
+ packet[4] = (srIdx and 0x1F).toByte() // audio protocol (0) | sr index
69
+ packet[6] = ((channels - 1) and 0xFF).toByte()
70
+ packet[7] = 0x01 // 16-bit PCM
71
+ for (i in 0 until 16) {
72
+ packet[8 + i] = if (i < streamName.length) (streamName[i].code and 0x7F).toByte() else 0
73
+ }
74
+
75
+ var frameCount = 0L
76
+ var peak = 0f
77
+ var packets = 0L
78
+ var lastEmit = System.currentTimeMillis()
79
+
80
+ try {
81
+ while (running) {
82
+ // Fill one packet's worth of samples.
83
+ var read = 0
84
+ val need = shortBuf.size
85
+ while (read < need && running) {
86
+ val n = record.read(shortBuf, read, need - read)
87
+ if (n <= 0) {
88
+ if (n < 0) onError("AudioRecord read error: $n")
89
+ break
90
+ }
91
+ read += n
92
+ }
93
+ if (read < channels) continue
94
+ val framesInPacket = read / channels
95
+
96
+ packet[5] = ((framesInPacket - 1) and 0xFF).toByte()
97
+ packet[24] = (frameCount and 0xFF).toByte()
98
+ packet[25] = ((frameCount shr 8) and 0xFF).toByte()
99
+ packet[26] = ((frameCount shr 16) and 0xFF).toByte()
100
+ packet[27] = ((frameCount shr 24) and 0xFF).toByte()
101
+
102
+ var bi = 28
103
+ var localPeak = 0
104
+ val total = framesInPacket * channels
105
+ for (i in 0 until total) {
106
+ val s = shortBuf[i].toInt()
107
+ packet[bi++] = (s and 0xFF).toByte()
108
+ packet[bi++] = ((s shr 8) and 0xFF).toByte()
109
+ val a = if (s < 0) -s else s
110
+ if (a > localPeak) localPeak = a
111
+ }
112
+
113
+ socket.send(DatagramPacket(packet, 28 + total * 2, addr, port))
114
+ frameCount = (frameCount + 1) and 0xFFFFFFFFL
115
+ packets++
116
+
117
+ val pk = localPeak / 32768f
118
+ if (pk > peak) peak = pk
119
+ val now = System.currentTimeMillis()
120
+ if (now - lastEmit >= 100) {
121
+ onStats(peak, packets)
122
+ lastEmit = now
123
+ peak = 0f
124
+ }
125
+ }
126
+ } catch (e: Exception) {
127
+ onError(e.message ?: "send error")
128
+ } finally {
129
+ try { socket.close() } catch (_: Exception) {}
130
+ try { record.stop() } catch (_: Exception) {}
131
+ try { record.release() } catch (_: Exception) {}
132
+ }
133
+ }.also { it.start() }
134
+ }
135
+
136
+ fun stop() {
137
+ running = false
138
+ try { thread?.join(400) } catch (_: Exception) {}
139
+ thread = null
140
+ }
141
+ }