@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 +87 -0
- package/android/build.gradle +15 -0
- package/android/src/main/AndroidManifest.xml +12 -0
- package/android/src/main/java/expo/modules/systemaudiocapture/SystemAudioCaptureModule.kt +57 -0
- package/android/src/main/java/expo/modules/systemaudiocapture/SystemAudioCaptureService.kt +151 -0
- package/android/src/main/java/expo/modules/systemaudiocapture/VbanReceiverModule.kt +216 -0
- package/android/src/main/java/expo/modules/systemaudiocapture/VbanSendEngine.kt +141 -0
- package/android/src/main/java/expo/modules/systemaudiocapture/VbanSenderModule.kt +129 -0
- package/expo-module.config.json +10 -0
- package/package.json +35 -0
- package/src/NativeVbanReceiver.ts +71 -0
- package/src/NativeVbanSender.ts +131 -0
- package/src/index.ts +31 -0
- package/src/types.ts +17 -0
- package/src/vban/constants.ts +93 -0
- package/src/vban/index.ts +3 -0
- package/src/vban/packet.ts +159 -0
- package/src/vban/pcm.ts +99 -0
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
|
+
}
|