@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
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
package expo.modules.systemaudiocapture
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.content.Intent
|
|
5
|
+
import android.media.AudioFormat
|
|
6
|
+
import android.media.AudioRecord
|
|
7
|
+
import android.media.MediaRecorder
|
|
8
|
+
import android.os.Build
|
|
9
|
+
import expo.modules.kotlin.Promise
|
|
10
|
+
import expo.modules.kotlin.exception.Exceptions
|
|
11
|
+
import expo.modules.kotlin.modules.Module
|
|
12
|
+
import expo.modules.kotlin.modules.ModuleDefinition
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Native VBAN sender. Captures audio and streams it out as VBAN entirely in
|
|
16
|
+
* native code (no JS bridge in the hot path), so packets are evenly paced and
|
|
17
|
+
* the receiver doesn't see jitter/errors.
|
|
18
|
+
*
|
|
19
|
+
* - Microphone: builds an AudioRecord(MIC) here and runs a VbanSendEngine.
|
|
20
|
+
* - Device audio: delegates to SystemAudioCaptureService (foreground service +
|
|
21
|
+
* MediaProjection), which runs its own VbanSendEngine.
|
|
22
|
+
*/
|
|
23
|
+
class VbanSenderModule : Module() {
|
|
24
|
+
private var engine: VbanSendEngine? = null
|
|
25
|
+
private var deviceActive = false
|
|
26
|
+
|
|
27
|
+
private val context: Context
|
|
28
|
+
get() = appContext.reactContext ?: throw Exceptions.ReactContextLost()
|
|
29
|
+
|
|
30
|
+
override fun definition() = ModuleDefinition {
|
|
31
|
+
Name("VbanNativeSender")
|
|
32
|
+
Events("onStats", "onError")
|
|
33
|
+
|
|
34
|
+
AsyncFunction("startMic") {
|
|
35
|
+
host: String, port: Int, streamName: String, sampleRate: Int, channels: Int, promise: Promise ->
|
|
36
|
+
try {
|
|
37
|
+
startMic(host, port, streamName, sampleRate, channels)
|
|
38
|
+
promise.resolve(null)
|
|
39
|
+
} catch (e: Exception) {
|
|
40
|
+
promise.reject("MIC_START_FAILED", e.message, e)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
AsyncFunction("startDevice") {
|
|
45
|
+
host: String, port: Int, streamName: String, sampleRate: Int, channels: Int, promise: Promise ->
|
|
46
|
+
if (SystemAudioCaptureService.resultData == null) {
|
|
47
|
+
promise.reject("NO_PERMISSION", "Request device-capture permission first", null)
|
|
48
|
+
return@AsyncFunction
|
|
49
|
+
}
|
|
50
|
+
SystemAudioCaptureService.host = host
|
|
51
|
+
SystemAudioCaptureService.port = port
|
|
52
|
+
SystemAudioCaptureService.streamName = streamName
|
|
53
|
+
SystemAudioCaptureService.sampleRate = sampleRate
|
|
54
|
+
SystemAudioCaptureService.channels = channels
|
|
55
|
+
SystemAudioCaptureService.onStats = { level, packets ->
|
|
56
|
+
sendEvent("onStats", mapOf("level" to level, "packets" to packets))
|
|
57
|
+
}
|
|
58
|
+
SystemAudioCaptureService.onErr = { msg ->
|
|
59
|
+
sendEvent("onError", mapOf("message" to msg))
|
|
60
|
+
}
|
|
61
|
+
val intent = Intent(context, SystemAudioCaptureService::class.java).apply {
|
|
62
|
+
action = SystemAudioCaptureService.ACTION_START
|
|
63
|
+
}
|
|
64
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
65
|
+
context.startForegroundService(intent)
|
|
66
|
+
} else {
|
|
67
|
+
context.startService(intent)
|
|
68
|
+
}
|
|
69
|
+
deviceActive = true
|
|
70
|
+
promise.resolve(null)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
AsyncFunction("stop") { promise: Promise ->
|
|
74
|
+
engine?.stop()
|
|
75
|
+
engine = null
|
|
76
|
+
if (deviceActive) {
|
|
77
|
+
deviceActive = false
|
|
78
|
+
SystemAudioCaptureService.onStats = null
|
|
79
|
+
SystemAudioCaptureService.onErr = null
|
|
80
|
+
val intent = Intent(context, SystemAudioCaptureService::class.java).apply {
|
|
81
|
+
action = SystemAudioCaptureService.ACTION_STOP
|
|
82
|
+
}
|
|
83
|
+
context.startService(intent)
|
|
84
|
+
}
|
|
85
|
+
promise.resolve(null)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
OnDestroy {
|
|
89
|
+
engine?.stop()
|
|
90
|
+
engine = null
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
private fun startMic(host: String, port: Int, streamName: String, sampleRate: Int, channels: Int) {
|
|
95
|
+
engine?.stop()
|
|
96
|
+
|
|
97
|
+
val record = buildMicRecord(sampleRate, channels)
|
|
98
|
+
val actualChannels = if (record.channelCount > 0) record.channelCount else channels
|
|
99
|
+
|
|
100
|
+
engine = VbanSendEngine(
|
|
101
|
+
record, host, port, streamName, sampleRate, actualChannels,
|
|
102
|
+
onStats = { level, packets -> sendEvent("onStats", mapOf("level" to level, "packets" to packets)) },
|
|
103
|
+
onError = { msg -> sendEvent("onError", mapOf("message" to msg)) },
|
|
104
|
+
)
|
|
105
|
+
engine!!.start()
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Build an AudioRecord for the mic, falling back to mono if stereo isn't supported. */
|
|
109
|
+
private fun buildMicRecord(sampleRate: Int, channels: Int): AudioRecord {
|
|
110
|
+
fun make(ch: Int): AudioRecord {
|
|
111
|
+
val mask = if (ch == 1) AudioFormat.CHANNEL_IN_MONO else AudioFormat.CHANNEL_IN_STEREO
|
|
112
|
+
val minBuf = AudioRecord.getMinBufferSize(sampleRate, mask, AudioFormat.ENCODING_PCM_16BIT)
|
|
113
|
+
val bufSize = if (minBuf > 0) minBuf * 2 else sampleRate * ch
|
|
114
|
+
return AudioRecord(
|
|
115
|
+
MediaRecorder.AudioSource.MIC, sampleRate, mask, AudioFormat.ENCODING_PCM_16BIT, bufSize
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
var record = make(channels)
|
|
119
|
+
if (record.state != AudioRecord.STATE_INITIALIZED && channels != 1) {
|
|
120
|
+
try { record.release() } catch (_: Exception) {}
|
|
121
|
+
record = make(1)
|
|
122
|
+
}
|
|
123
|
+
if (record.state != AudioRecord.STATE_INITIALIZED) {
|
|
124
|
+
try { record.release() } catch (_: Exception) {}
|
|
125
|
+
throw IllegalStateException("Could not initialize microphone capture")
|
|
126
|
+
}
|
|
127
|
+
return record
|
|
128
|
+
}
|
|
129
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@parastud/react-native-vban",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "VBAN (VB-Audio Network) audio-over-IP for React Native / Expo — native UDP send & receive, Android system-audio capture via MediaProjection, and a pure-TypeScript VBAN codec. Interoperates with Voicemeeter and other VBAN endpoints.",
|
|
5
|
+
"main": "src/index.ts",
|
|
6
|
+
"types": "src/index.ts",
|
|
7
|
+
"author": "Parastud <tech@rastaa.ai>",
|
|
8
|
+
"license": "MIT",
|
|
9
|
+
"keywords": [
|
|
10
|
+
"vban",
|
|
11
|
+
"voicemeeter",
|
|
12
|
+
"audio-over-ip",
|
|
13
|
+
"udp",
|
|
14
|
+
"react-native",
|
|
15
|
+
"expo",
|
|
16
|
+
"android",
|
|
17
|
+
"audio",
|
|
18
|
+
"streaming",
|
|
19
|
+
"mediaprojection"
|
|
20
|
+
],
|
|
21
|
+
"files": [
|
|
22
|
+
"src",
|
|
23
|
+
"android",
|
|
24
|
+
"expo-module.config.json",
|
|
25
|
+
"README.md"
|
|
26
|
+
],
|
|
27
|
+
"peerDependencies": {
|
|
28
|
+
"expo": "*",
|
|
29
|
+
"react": "*",
|
|
30
|
+
"react-native": "*"
|
|
31
|
+
},
|
|
32
|
+
"expo": {
|
|
33
|
+
"platforms": ["android"]
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JS wrapper over the native `VbanNativeReceiver` module. The native side owns
|
|
3
|
+
* the UDP socket + AudioTrack playback; here we just start/stop it and relay the
|
|
4
|
+
* throttled stats events used to drive the meters and stream list.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { requireOptionalNativeModule } from 'expo-modules-core';
|
|
8
|
+
|
|
9
|
+
export interface VbanStatsEvent {
|
|
10
|
+
streamName: string;
|
|
11
|
+
address: string;
|
|
12
|
+
sampleRate: number;
|
|
13
|
+
channels: number;
|
|
14
|
+
level: number;
|
|
15
|
+
dropped: number;
|
|
16
|
+
packets: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface NativeReceiver {
|
|
20
|
+
start(port: number, filter: string | null): Promise<void>;
|
|
21
|
+
stop(): Promise<void>;
|
|
22
|
+
addListener(event: 'onStats', cb: (e: VbanStatsEvent) => void): { remove(): void };
|
|
23
|
+
addListener(event: 'onError', cb: (e: { message: string }) => void): { remove(): void };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const Native = requireOptionalNativeModule<NativeReceiver>('VbanNativeReceiver');
|
|
27
|
+
|
|
28
|
+
/** True when the native receive+play path is available in this build. */
|
|
29
|
+
export function isNativeReceiverAvailable(): boolean {
|
|
30
|
+
return Native != null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export class NativeVbanReceiver {
|
|
34
|
+
private statsSub: { remove(): void } | null = null;
|
|
35
|
+
private errSub: { remove(): void } | null = null;
|
|
36
|
+
private running = false;
|
|
37
|
+
|
|
38
|
+
get isRunning(): boolean {
|
|
39
|
+
return this.running;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async start(
|
|
43
|
+
opts: { port: number; streamNameFilter?: string },
|
|
44
|
+
onStats: (e: VbanStatsEvent) => void,
|
|
45
|
+
onError?: (message: string) => void,
|
|
46
|
+
): Promise<void> {
|
|
47
|
+
if (!Native) throw new Error('Native receiver not available in this build');
|
|
48
|
+
if (this.running) return;
|
|
49
|
+
|
|
50
|
+
this.statsSub = Native.addListener('onStats', onStats);
|
|
51
|
+
this.errSub = Native.addListener('onError', (e) => onError?.(e.message));
|
|
52
|
+
|
|
53
|
+
await Native.start(opts.port, opts.streamNameFilter ?? null);
|
|
54
|
+
this.running = true;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async stop(): Promise<void> {
|
|
58
|
+
this.running = false;
|
|
59
|
+
this.statsSub?.remove();
|
|
60
|
+
this.statsSub = null;
|
|
61
|
+
this.errSub?.remove();
|
|
62
|
+
this.errSub = null;
|
|
63
|
+
if (Native) {
|
|
64
|
+
try {
|
|
65
|
+
await Native.stop();
|
|
66
|
+
} catch {
|
|
67
|
+
// ignore
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JS wrapper over the native `VbanNativeSender` module. Native code owns the
|
|
3
|
+
* capture + VBAN packetization + UDP send, so the JS bridge is never in the hot
|
|
4
|
+
* path (evenly paced packets, no receiver-side jitter). We only start/stop it
|
|
5
|
+
* and relay the throttled level stats.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { requireOptionalNativeModule } from 'expo-modules-core';
|
|
9
|
+
import { PermissionsAndroid, Platform } from 'react-native';
|
|
10
|
+
|
|
11
|
+
import type { SendSource } from './types';
|
|
12
|
+
|
|
13
|
+
interface NativeSender {
|
|
14
|
+
startMic(
|
|
15
|
+
host: string,
|
|
16
|
+
port: number,
|
|
17
|
+
streamName: string,
|
|
18
|
+
sampleRate: number,
|
|
19
|
+
channels: number,
|
|
20
|
+
): Promise<void>;
|
|
21
|
+
startDevice(
|
|
22
|
+
host: string,
|
|
23
|
+
port: number,
|
|
24
|
+
streamName: string,
|
|
25
|
+
sampleRate: number,
|
|
26
|
+
channels: number,
|
|
27
|
+
): Promise<void>;
|
|
28
|
+
stop(): Promise<void>;
|
|
29
|
+
addListener(event: 'onStats', cb: (e: { level: number; packets: number }) => void): { remove(): void };
|
|
30
|
+
addListener(event: 'onError', cb: (e: { message: string }) => void): { remove(): void };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface NativeCapture {
|
|
34
|
+
isSupported(): boolean;
|
|
35
|
+
requestPermission(): Promise<boolean>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const Native = requireOptionalNativeModule<NativeSender>('VbanNativeSender');
|
|
39
|
+
const Capture = requireOptionalNativeModule<NativeCapture>('SystemAudioCapture');
|
|
40
|
+
|
|
41
|
+
export function isNativeSenderAvailable(): boolean {
|
|
42
|
+
return Native != null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** True when device (system) audio capture is supported on this OS/build. */
|
|
46
|
+
export function isDeviceCaptureSupported(): boolean {
|
|
47
|
+
try {
|
|
48
|
+
return Capture != null && Capture.isSupported();
|
|
49
|
+
} catch {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function ensureMicPermission(): Promise<void> {
|
|
55
|
+
if (Platform.OS !== 'android') return;
|
|
56
|
+
const result = await PermissionsAndroid.request(PermissionsAndroid.PERMISSIONS.RECORD_AUDIO);
|
|
57
|
+
if (result !== PermissionsAndroid.RESULTS.GRANTED) {
|
|
58
|
+
throw new Error('Microphone permission denied');
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface NativeSendConfig {
|
|
63
|
+
host: string;
|
|
64
|
+
port: number;
|
|
65
|
+
streamName: string;
|
|
66
|
+
sampleRate: number;
|
|
67
|
+
channels: number;
|
|
68
|
+
source: SendSource;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export class NativeVbanSender {
|
|
72
|
+
private statsSub: { remove(): void } | null = null;
|
|
73
|
+
private errSub: { remove(): void } | null = null;
|
|
74
|
+
private running = false;
|
|
75
|
+
|
|
76
|
+
get isRunning(): boolean {
|
|
77
|
+
return this.running;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async start(
|
|
81
|
+
config: NativeSendConfig,
|
|
82
|
+
onStats: (level: number) => void,
|
|
83
|
+
onError?: (message: string) => void,
|
|
84
|
+
): Promise<void> {
|
|
85
|
+
if (!Native) throw new Error('Native sender not available in this build');
|
|
86
|
+
if (this.running) return;
|
|
87
|
+
|
|
88
|
+
// Both mic and device capture use AudioRecord, which needs the mic permission.
|
|
89
|
+
await ensureMicPermission();
|
|
90
|
+
|
|
91
|
+
this.statsSub = Native.addListener('onStats', (e) => onStats(e.level));
|
|
92
|
+
this.errSub = Native.addListener('onError', (e) => onError?.(e.message));
|
|
93
|
+
|
|
94
|
+
if (config.source === 'device') {
|
|
95
|
+
if (!Capture) throw new Error('Device audio capture not available');
|
|
96
|
+
const granted = await Capture.requestPermission();
|
|
97
|
+
if (!granted) throw new Error('Screen/audio capture was not allowed');
|
|
98
|
+
await Native.startDevice(
|
|
99
|
+
config.host,
|
|
100
|
+
config.port,
|
|
101
|
+
config.streamName,
|
|
102
|
+
config.sampleRate,
|
|
103
|
+
config.channels,
|
|
104
|
+
);
|
|
105
|
+
} else {
|
|
106
|
+
await Native.startMic(
|
|
107
|
+
config.host,
|
|
108
|
+
config.port,
|
|
109
|
+
config.streamName,
|
|
110
|
+
config.sampleRate,
|
|
111
|
+
config.channels,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
this.running = true;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async stop(): Promise<void> {
|
|
118
|
+
this.running = false;
|
|
119
|
+
this.statsSub?.remove();
|
|
120
|
+
this.statsSub = null;
|
|
121
|
+
this.errSub?.remove();
|
|
122
|
+
this.errSub = null;
|
|
123
|
+
if (Native) {
|
|
124
|
+
try {
|
|
125
|
+
await Native.stop();
|
|
126
|
+
} catch {
|
|
127
|
+
// ignore
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @parastud/react-native-vban
|
|
3
|
+
*
|
|
4
|
+
* VBAN (VB-Audio Network) audio-over-IP for React Native / Expo.
|
|
5
|
+
*
|
|
6
|
+
* - Native UDP receive + AudioTrack playback (VbanNativeReceiver)
|
|
7
|
+
* - Native capture + VBAN + UDP send (VbanNativeSender)
|
|
8
|
+
* - Android system/playback audio capture via MediaProjection
|
|
9
|
+
* - A pure-TypeScript VBAN codec (packet encode/decode + PCM conversion)
|
|
10
|
+
*
|
|
11
|
+
* Android-only for now. Interoperates with Voicemeeter and other VBAN endpoints.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
// Pure-TS VBAN codec (framework-agnostic).
|
|
15
|
+
export * from './vban';
|
|
16
|
+
|
|
17
|
+
// Shared types.
|
|
18
|
+
export * from './types';
|
|
19
|
+
|
|
20
|
+
// Native receive + send.
|
|
21
|
+
export {
|
|
22
|
+
NativeVbanReceiver,
|
|
23
|
+
isNativeReceiverAvailable,
|
|
24
|
+
type VbanStatsEvent,
|
|
25
|
+
} from './NativeVbanReceiver';
|
|
26
|
+
export {
|
|
27
|
+
NativeVbanSender,
|
|
28
|
+
isNativeSenderAvailable,
|
|
29
|
+
isDeviceCaptureSupported,
|
|
30
|
+
type NativeSendConfig,
|
|
31
|
+
} from './NativeVbanSender';
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** Where outbound audio is captured from. */
|
|
2
|
+
export type SendSource = 'mic' | 'device';
|
|
3
|
+
|
|
4
|
+
/** A VBAN audio stream the receiver has seen, for showing a picker/list in UI. */
|
|
5
|
+
export interface DiscoveredStream {
|
|
6
|
+
key: string;
|
|
7
|
+
streamName: string;
|
|
8
|
+
address: string;
|
|
9
|
+
port: number;
|
|
10
|
+
sampleRate: number;
|
|
11
|
+
channels: number;
|
|
12
|
+
dataType: number;
|
|
13
|
+
/** Most recent peak level [0, 1]. */
|
|
14
|
+
level: number;
|
|
15
|
+
packets: number;
|
|
16
|
+
dropped: number;
|
|
17
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VBAN (VB-Audio Network) protocol constants.
|
|
3
|
+
*
|
|
4
|
+
* Reference: VBAN packet specification by VB-Audio.
|
|
5
|
+
* A VBAN packet = 28-byte header + payload. All multi-byte fields are
|
|
6
|
+
* little-endian. The header layout is:
|
|
7
|
+
*
|
|
8
|
+
* offset size field
|
|
9
|
+
* 0 4 magic 'VBAN'
|
|
10
|
+
* 4 1 formatSR : bits 0-4 sample-rate index, bits 5-7 sub-protocol
|
|
11
|
+
* 5 1 formatNBS : samples-per-frame minus 1 (1..256)
|
|
12
|
+
* 6 1 formatNBC : channels minus 1 (1..256)
|
|
13
|
+
* 7 1 formatBIT : bits 0-2 data type, bit 3 reserved, bits 4-7 codec
|
|
14
|
+
* 8 16 streamName : ASCII, NUL-padded
|
|
15
|
+
* 24 4 frameCount : uint32 little-endian
|
|
16
|
+
* 28 .. payload
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export const VBAN_MAGIC = 0x4e414256; // 'VBAN' read as little-endian uint32 ('V'=0x56 first byte)
|
|
20
|
+
export const VBAN_HEADER_SIZE = 28;
|
|
21
|
+
export const VBAN_STREAMNAME_SIZE = 16;
|
|
22
|
+
|
|
23
|
+
/** Default UDP port used by VBAN. */
|
|
24
|
+
export const VBAN_DEFAULT_PORT = 6980;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Max audio payload bytes per packet (keeps the whole datagram under a typical
|
|
28
|
+
* MTU). Constraint from the spec: nbc * nbs * sampleSize <= this value.
|
|
29
|
+
*/
|
|
30
|
+
export const VBAN_MAX_PAYLOAD = 1436;
|
|
31
|
+
|
|
32
|
+
/** Sample-rate table, indexed by the 5-bit sample-rate field. */
|
|
33
|
+
export const VBAN_SAMPLE_RATES = [
|
|
34
|
+
6000, 12000, 24000, 48000, 96000, 192000, 384000,
|
|
35
|
+
8000, 16000, 32000, 64000, 128000, 256000, 512000,
|
|
36
|
+
11025, 22050, 44100, 88200, 176400, 352800, 705600,
|
|
37
|
+
] as const;
|
|
38
|
+
|
|
39
|
+
/** Sub-protocol, stored in bits 5-7 of formatSR. */
|
|
40
|
+
export enum VbanProtocol {
|
|
41
|
+
Audio = 0x00,
|
|
42
|
+
Serial = 0x20,
|
|
43
|
+
Text = 0x40,
|
|
44
|
+
Service = 0x60,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Data type of a single sample, stored in bits 0-2 of formatBIT. */
|
|
48
|
+
export enum VbanDataType {
|
|
49
|
+
Byte8 = 0x00, // signed 8-bit
|
|
50
|
+
Int16 = 0x01,
|
|
51
|
+
Int24 = 0x02,
|
|
52
|
+
Int32 = 0x03,
|
|
53
|
+
Float32 = 0x04,
|
|
54
|
+
Float64 = 0x05,
|
|
55
|
+
Bits12 = 0x06,
|
|
56
|
+
Bits10 = 0x07,
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Codec, stored in bits 4-7 of formatBIT. Only PCM is uncompressed. */
|
|
60
|
+
export enum VbanCodec {
|
|
61
|
+
PCM = 0x00,
|
|
62
|
+
VBCA = 0x10, // VB-Audio AOIP codec (compressed)
|
|
63
|
+
VBCV = 0x20, // VB-Audio VOIP codec (compressed)
|
|
64
|
+
User = 0xf0,
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Size in bytes of one sample for a given data type (PCM only). */
|
|
68
|
+
export const DATA_TYPE_SIZE: Record<VbanDataType, number> = {
|
|
69
|
+
[VbanDataType.Byte8]: 1,
|
|
70
|
+
[VbanDataType.Int16]: 2,
|
|
71
|
+
[VbanDataType.Int24]: 3,
|
|
72
|
+
[VbanDataType.Int32]: 4,
|
|
73
|
+
[VbanDataType.Float32]: 4,
|
|
74
|
+
[VbanDataType.Float64]: 8,
|
|
75
|
+
[VbanDataType.Bits12]: 2, // packed oddly; not supported for encode/decode yet
|
|
76
|
+
[VbanDataType.Bits10]: 2,
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/** Bit masks for the packed header fields. */
|
|
80
|
+
export const MASK_SR_INDEX = 0x1f;
|
|
81
|
+
export const MASK_PROTOCOL = 0xe0;
|
|
82
|
+
export const MASK_DATA_TYPE = 0x07;
|
|
83
|
+
export const MASK_CODEC = 0xf0;
|
|
84
|
+
|
|
85
|
+
/** Look up the Hz value for a sample-rate index, or undefined if out of range. */
|
|
86
|
+
export function sampleRateFromIndex(index: number): number | undefined {
|
|
87
|
+
return VBAN_SAMPLE_RATES[index];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Find the sample-rate index for a Hz value, or -1 if it is not a VBAN rate. */
|
|
91
|
+
export function indexFromSampleRate(hz: number): number {
|
|
92
|
+
return VBAN_SAMPLE_RATES.indexOf(hz as (typeof VBAN_SAMPLE_RATES)[number]);
|
|
93
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VBAN packet header encode/decode. Pure, dependency-free, and RN/Hermes-safe
|
|
3
|
+
* (uses only Uint8Array + DataView, no Node Buffer).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
DATA_TYPE_SIZE,
|
|
8
|
+
MASK_CODEC,
|
|
9
|
+
MASK_DATA_TYPE,
|
|
10
|
+
MASK_PROTOCOL,
|
|
11
|
+
MASK_SR_INDEX,
|
|
12
|
+
VBAN_HEADER_SIZE,
|
|
13
|
+
VBAN_STREAMNAME_SIZE,
|
|
14
|
+
VbanCodec,
|
|
15
|
+
VbanDataType,
|
|
16
|
+
VbanProtocol,
|
|
17
|
+
indexFromSampleRate,
|
|
18
|
+
sampleRateFromIndex,
|
|
19
|
+
} from './constants';
|
|
20
|
+
|
|
21
|
+
export interface VbanHeader {
|
|
22
|
+
/** Sub-protocol (audio, serial, text, service). */
|
|
23
|
+
protocol: VbanProtocol;
|
|
24
|
+
/** Sample rate in Hz. Must be one of the VBAN rates. */
|
|
25
|
+
sampleRate: number;
|
|
26
|
+
/** Samples per channel in this packet (1..256). */
|
|
27
|
+
samplesPerFrame: number;
|
|
28
|
+
/** Number of channels (1..256). */
|
|
29
|
+
channels: number;
|
|
30
|
+
/** Sample data type. */
|
|
31
|
+
dataType: VbanDataType;
|
|
32
|
+
/** Codec (only PCM is uncompressed and supported here). */
|
|
33
|
+
codec: VbanCodec;
|
|
34
|
+
/** Stream name, up to 16 ASCII chars. */
|
|
35
|
+
streamName: string;
|
|
36
|
+
/** Frame counter, uint32. Increments per packet sent on a stream. */
|
|
37
|
+
frameCount: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface VbanPacket {
|
|
41
|
+
header: VbanHeader;
|
|
42
|
+
/** Raw payload bytes (the audio samples, still in their wire data type). */
|
|
43
|
+
payload: Uint8Array;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const MAGIC = [0x56, 0x42, 0x41, 0x4e]; // 'V','B','A','N'
|
|
47
|
+
|
|
48
|
+
/** Write up to 16 ASCII bytes of `name` into `dst` at `offset`, NUL-padded. */
|
|
49
|
+
function writeStreamName(dst: Uint8Array, offset: number, name: string): void {
|
|
50
|
+
for (let i = 0; i < VBAN_STREAMNAME_SIZE; i++) {
|
|
51
|
+
const code = i < name.length ? name.charCodeAt(i) & 0x7f : 0;
|
|
52
|
+
dst[offset + i] = code;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Read a NUL-terminated ASCII stream name of at most 16 bytes. */
|
|
57
|
+
function readStreamName(src: Uint8Array, offset: number): string {
|
|
58
|
+
let out = '';
|
|
59
|
+
for (let i = 0; i < VBAN_STREAMNAME_SIZE; i++) {
|
|
60
|
+
const code = src[offset + i];
|
|
61
|
+
if (code === 0) break;
|
|
62
|
+
out += String.fromCharCode(code);
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Encode a 28-byte VBAN header into `out` at `offset` (defaults to a new array). */
|
|
68
|
+
export function encodeHeader(
|
|
69
|
+
header: VbanHeader,
|
|
70
|
+
out: Uint8Array = new Uint8Array(VBAN_HEADER_SIZE),
|
|
71
|
+
offset = 0,
|
|
72
|
+
): Uint8Array {
|
|
73
|
+
const srIndex = indexFromSampleRate(header.sampleRate);
|
|
74
|
+
if (srIndex < 0) {
|
|
75
|
+
throw new Error(`Unsupported VBAN sample rate: ${header.sampleRate}`);
|
|
76
|
+
}
|
|
77
|
+
if (header.samplesPerFrame < 1 || header.samplesPerFrame > 256) {
|
|
78
|
+
throw new Error(`samplesPerFrame out of range (1..256): ${header.samplesPerFrame}`);
|
|
79
|
+
}
|
|
80
|
+
if (header.channels < 1 || header.channels > 256) {
|
|
81
|
+
throw new Error(`channels out of range (1..256): ${header.channels}`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const view = new DataView(out.buffer, out.byteOffset + offset, VBAN_HEADER_SIZE);
|
|
85
|
+
out[offset + 0] = MAGIC[0];
|
|
86
|
+
out[offset + 1] = MAGIC[1];
|
|
87
|
+
out[offset + 2] = MAGIC[2];
|
|
88
|
+
out[offset + 3] = MAGIC[3];
|
|
89
|
+
out[offset + 4] = (srIndex & MASK_SR_INDEX) | (header.protocol & MASK_PROTOCOL);
|
|
90
|
+
out[offset + 5] = header.samplesPerFrame - 1;
|
|
91
|
+
out[offset + 6] = header.channels - 1;
|
|
92
|
+
out[offset + 7] = (header.dataType & MASK_DATA_TYPE) | (header.codec & MASK_CODEC);
|
|
93
|
+
writeStreamName(out, offset + 8, header.streamName);
|
|
94
|
+
view.setUint32(24, header.frameCount >>> 0, true);
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Decode a 28-byte VBAN header from `src` at `offset`. Throws on bad magic. */
|
|
99
|
+
export function decodeHeader(src: Uint8Array, offset = 0): VbanHeader {
|
|
100
|
+
if (src.length - offset < VBAN_HEADER_SIZE) {
|
|
101
|
+
throw new Error('Buffer too small for a VBAN header');
|
|
102
|
+
}
|
|
103
|
+
if (
|
|
104
|
+
src[offset + 0] !== MAGIC[0] ||
|
|
105
|
+
src[offset + 1] !== MAGIC[1] ||
|
|
106
|
+
src[offset + 2] !== MAGIC[2] ||
|
|
107
|
+
src[offset + 3] !== MAGIC[3]
|
|
108
|
+
) {
|
|
109
|
+
throw new Error('Not a VBAN packet (bad magic)');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const view = new DataView(src.buffer, src.byteOffset + offset, VBAN_HEADER_SIZE);
|
|
113
|
+
const formatSR = src[offset + 4];
|
|
114
|
+
const formatBIT = src[offset + 7];
|
|
115
|
+
|
|
116
|
+
const srIndex = formatSR & MASK_SR_INDEX;
|
|
117
|
+
const sampleRate = sampleRateFromIndex(srIndex);
|
|
118
|
+
if (sampleRate === undefined) {
|
|
119
|
+
throw new Error(`Reserved VBAN sample-rate index: ${srIndex}`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
protocol: (formatSR & MASK_PROTOCOL) as VbanProtocol,
|
|
124
|
+
sampleRate,
|
|
125
|
+
samplesPerFrame: src[offset + 5] + 1,
|
|
126
|
+
channels: src[offset + 6] + 1,
|
|
127
|
+
dataType: (formatBIT & MASK_DATA_TYPE) as VbanDataType,
|
|
128
|
+
codec: (formatBIT & MASK_CODEC) as VbanCodec,
|
|
129
|
+
streamName: readStreamName(src, offset + 8),
|
|
130
|
+
frameCount: view.getUint32(24, true),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Build a complete VBAN datagram from a header and already-encoded payload
|
|
136
|
+
* bytes. `header.samplesPerFrame`/`channels`/`dataType` should match the
|
|
137
|
+
* payload length; a mismatch throws to catch bugs early.
|
|
138
|
+
*/
|
|
139
|
+
export function encodePacket(header: VbanHeader, payload: Uint8Array): Uint8Array {
|
|
140
|
+
const sampleSize = DATA_TYPE_SIZE[header.dataType];
|
|
141
|
+
const expected = header.samplesPerFrame * header.channels * sampleSize;
|
|
142
|
+
if (payload.length !== expected) {
|
|
143
|
+
throw new Error(
|
|
144
|
+
`Payload length ${payload.length} != expected ${expected} ` +
|
|
145
|
+
`(${header.samplesPerFrame} samples x ${header.channels} ch x ${sampleSize} bytes)`,
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
const packet = new Uint8Array(VBAN_HEADER_SIZE + payload.length);
|
|
149
|
+
encodeHeader(header, packet, 0);
|
|
150
|
+
packet.set(payload, VBAN_HEADER_SIZE);
|
|
151
|
+
return packet;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Parse a received datagram into its header and payload view. */
|
|
155
|
+
export function decodePacket(datagram: Uint8Array): VbanPacket {
|
|
156
|
+
const header = decodeHeader(datagram, 0);
|
|
157
|
+
const payload = datagram.subarray(VBAN_HEADER_SIZE);
|
|
158
|
+
return { header, payload };
|
|
159
|
+
}
|