@sonata-sdk/voice 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/LICENSE +21 -0
- package/README.md +144 -0
- package/dist/connection.d.ts +29 -0
- package/dist/connection.js +107 -0
- package/dist/encryption.d.ts +16 -0
- package/dist/encryption.js +97 -0
- package/dist/gateway.d.ts +36 -0
- package/dist/gateway.js +153 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +4 -0
- package/dist/types.d.ts +61 -0
- package/dist/types.js +1 -0
- package/dist/udp.d.ts +13 -0
- package/dist/udp.js +80 -0
- package/package.json +58 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sonata SDK
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
<h1>๐๏ธ @sonata-sdk/voice</h1>
|
|
3
|
+
<p><strong>Discord voice connection library</strong><br />WebSocket gateway ยท UDP ยท RTP ยท Encryption ยท DAVE/MLS</p>
|
|
4
|
+
<p>
|
|
5
|
+
<img src="https://img.shields.io/npm/v/@sonata-sdk/voice?color=blueviolet" alt="Version" />
|
|
6
|
+
<img src="https://img.shields.io/npm/l/@sonata-sdk/voice?color=blue" alt="License" />
|
|
7
|
+
<img src="https://img.shields.io/badge/node-20%2B-339933?logo=node.js" alt="Node" />
|
|
8
|
+
</p>
|
|
9
|
+
<p>
|
|
10
|
+
<a href="#-install">Install</a> โข
|
|
11
|
+
<a href="#-usage">Usage</a> โข
|
|
12
|
+
<a href="#-api">API</a> โข
|
|
13
|
+
<a href="#-related">Related</a>
|
|
14
|
+
</p>
|
|
15
|
+
<br />
|
|
16
|
+
<hr />
|
|
17
|
+
</div>
|
|
18
|
+
|
|
19
|
+
> Pure TypeScript Discord voice library. Connects to Discord's voice gateway, handles UDP audio, RTP packetization, encryption, and DAVE/MLS E2EE. No native dependencies, no C++.
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## ๐ฅ Install
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
npm install @sonata-sdk/voice
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
### Optional (for DAVE/E2EE)
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
npm install @snazzah/davey libsodium-wrappers
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
## ๐ Subpath imports
|
|
38
|
+
|
|
39
|
+
```ts
|
|
40
|
+
import { joinVoiceChannel } from '@sonata-sdk/voice'
|
|
41
|
+
import { VoiceGateway } from '@sonata-sdk/voice/gateway'
|
|
42
|
+
import { UdpSocket } from '@sonata-sdk/voice/udp'
|
|
43
|
+
import { AudioEncryption } from '@sonata-sdk/voice/encryption'
|
|
44
|
+
import type { VoiceConnection, EncryptionMode } from '@sonata-sdk/voice/types'
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
## ๐ Usage
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
import { joinVoiceChannel } from '@sonata-sdk/voice'
|
|
53
|
+
|
|
54
|
+
const connection = joinVoiceChannel({
|
|
55
|
+
guildId: '123',
|
|
56
|
+
userId: '456',
|
|
57
|
+
channelId: '789',
|
|
58
|
+
encryption: 'aead_aes256_gcm_rtpsize',
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
connection.voiceStateUpdate({ sessionId: 'abc' })
|
|
62
|
+
connection.voiceServerUpdate({ token: 'xyz', endpoint: 'gateway.discord.audio' })
|
|
63
|
+
|
|
64
|
+
connection.on('stateChange', (old, next) => {
|
|
65
|
+
if (next.status === 'connected') {
|
|
66
|
+
// Send audio every 20ms
|
|
67
|
+
setInterval(() => {
|
|
68
|
+
connection.sendAudioFrame(opusFrame)
|
|
69
|
+
}, 20)
|
|
70
|
+
}
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
connection.setSpeaking(1)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
---
|
|
77
|
+
|
|
78
|
+
## ๐ API
|
|
79
|
+
|
|
80
|
+
### `joinVoiceChannel(options)`
|
|
81
|
+
|
|
82
|
+
| Option | Type | Description |
|
|
83
|
+
|--------|------|-------------|
|
|
84
|
+
| `guildId` | `string` | Discord guild ID |
|
|
85
|
+
| `userId` | `string` | Bot user ID |
|
|
86
|
+
| `channelId` | `string` | Voice channel ID |
|
|
87
|
+
| `encryption` | `string \| null` | Encryption mode or null for auto |
|
|
88
|
+
|
|
89
|
+
Returns a `VoiceConnection`.
|
|
90
|
+
|
|
91
|
+
### `VoiceConnection`
|
|
92
|
+
|
|
93
|
+
| Property | Description |
|
|
94
|
+
|----------|-------------|
|
|
95
|
+
| `state` | `{ status, reason, code }` โ connection state |
|
|
96
|
+
| `playerState` | `{ status, reason }` โ playback state |
|
|
97
|
+
| `ping` | Voice websocket latency |
|
|
98
|
+
| `statistics` | `{ packetsSent, packetsLost, packetsExpected }` |
|
|
99
|
+
| `udpInfo` | `{ ssrc, ip, port, secretKey }` or `null` |
|
|
100
|
+
|
|
101
|
+
| Method | Description |
|
|
102
|
+
|--------|-------------|
|
|
103
|
+
| `voiceStateUpdate(obj)` | Feed Discord voice state |
|
|
104
|
+
| `voiceServerUpdate(obj)` | Feed Discord voice server |
|
|
105
|
+
| `sendAudioFrame(frame)` | Send an Opus frame |
|
|
106
|
+
| `setSpeaking(value)` | Set speaking flag (1 = speaking) |
|
|
107
|
+
| `destroy()` | Clean up connection |
|
|
108
|
+
| `on(event, fn)` | Listen to events |
|
|
109
|
+
| `removeAllListeners(event?)` | Remove listeners |
|
|
110
|
+
|
|
111
|
+
### Events
|
|
112
|
+
|
|
113
|
+
| Event | Payload | Description |
|
|
114
|
+
|-------|---------|-------------|
|
|
115
|
+
| `stateChange` | `(old, next)` | Connection state changed |
|
|
116
|
+
| `playerStateChange` | `(old, next)` | Player state changed |
|
|
117
|
+
| `error` | `(Error)` | An error occurred |
|
|
118
|
+
| `ready` | `()` | Voice connection established |
|
|
119
|
+
|
|
120
|
+
---
|
|
121
|
+
|
|
122
|
+
## ๐ Encryption Modes
|
|
123
|
+
|
|
124
|
+
| Mode | Algorithm |
|
|
125
|
+
|------|-----------|
|
|
126
|
+
| `aead_aes256_gcm_rtpsize` | AES-256-GCM |
|
|
127
|
+
| `aead_xchacha20_poly1305_rtpsize` | XChaCha20-Poly1305 |
|
|
128
|
+
| `xsalsa20_poly1305_lite_rtpsize` | XSalsa20-Poly1305 (lite) |
|
|
129
|
+
| `xsalsa20_poly1305_suffix_rtpsize` | XSalsa20-Poly1305 (suffix) |
|
|
130
|
+
| `normal` | XSalsa20-Poly1305 (legacy) |
|
|
131
|
+
|
|
132
|
+
---
|
|
133
|
+
|
|
134
|
+
## ๐ฆ Related
|
|
135
|
+
|
|
136
|
+
- [**Sonata**](https://github.com/sonata-sdk/sonata) โ Lavalink-compatible audio server
|
|
137
|
+
- [**@sonata-sdk/plugin-sdk**](https://github.com/sonata-sdk/sonata-sdk-packages/tree/main/packages/plugin-sdk) โ SDK for Sonata plugins
|
|
138
|
+
- [**sonata-sdk-packages**](https://github.com/sonata-sdk/sonata-sdk-packages) โ Monorepo
|
|
139
|
+
|
|
140
|
+
---
|
|
141
|
+
|
|
142
|
+
<div align="center">
|
|
143
|
+
<sub>MIT License ยท Built with โค๏ธ</sub>
|
|
144
|
+
</div>
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
import type { JoinVoiceOptions, ConnectionState, PlayerState, UdpInfo, ConnectionStatistics, VoiceConnection as IVoiceConnection } from './types.js';
|
|
3
|
+
export declare class VoiceConnection extends EventEmitter implements IVoiceConnection {
|
|
4
|
+
#private;
|
|
5
|
+
guildId: string;
|
|
6
|
+
userId: string;
|
|
7
|
+
channelId: string;
|
|
8
|
+
encryption: string | null;
|
|
9
|
+
state: ConnectionState;
|
|
10
|
+
playerState: PlayerState;
|
|
11
|
+
ping: number;
|
|
12
|
+
statistics: ConnectionStatistics;
|
|
13
|
+
udpInfo: UdpInfo | null;
|
|
14
|
+
constructor(opts: JoinVoiceOptions);
|
|
15
|
+
voiceStateUpdate(obj: {
|
|
16
|
+
session_id?: string;
|
|
17
|
+
sessionId?: string;
|
|
18
|
+
}): void;
|
|
19
|
+
voiceServerUpdate(obj: {
|
|
20
|
+
token: string;
|
|
21
|
+
endpoint: string;
|
|
22
|
+
channel_id?: string;
|
|
23
|
+
channelId?: string;
|
|
24
|
+
}): void;
|
|
25
|
+
sendAudioFrame(frame: Buffer): void;
|
|
26
|
+
setSpeaking(value: number): void;
|
|
27
|
+
destroy(): void;
|
|
28
|
+
}
|
|
29
|
+
export declare function joinVoiceChannel(options: JoinVoiceOptions): VoiceConnection;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
import { VoiceGateway } from './gateway.js';
|
|
3
|
+
import { UdpSocket } from './udp.js';
|
|
4
|
+
import { AudioEncryption } from './encryption.js';
|
|
5
|
+
export class VoiceConnection extends EventEmitter {
|
|
6
|
+
guildId;
|
|
7
|
+
userId;
|
|
8
|
+
channelId;
|
|
9
|
+
encryption;
|
|
10
|
+
state = { status: 'connecting', reason: null, code: null };
|
|
11
|
+
playerState = { status: 'stopped', reason: null };
|
|
12
|
+
ping = 0;
|
|
13
|
+
statistics = { packetsSent: 0, packetsLost: 0, packetsExpected: 0 };
|
|
14
|
+
udpInfo = null;
|
|
15
|
+
#gateway;
|
|
16
|
+
#udp;
|
|
17
|
+
#encryption = null;
|
|
18
|
+
#sessionId = '';
|
|
19
|
+
#token = '';
|
|
20
|
+
#endpoint = '';
|
|
21
|
+
#destroyed = false;
|
|
22
|
+
constructor(opts) {
|
|
23
|
+
super();
|
|
24
|
+
this.guildId = opts.guildId;
|
|
25
|
+
this.userId = opts.userId;
|
|
26
|
+
this.channelId = opts.channelId;
|
|
27
|
+
this.encryption = opts.encryption ?? null;
|
|
28
|
+
this.#gateway = new VoiceGateway(opts.guildId, opts.userId);
|
|
29
|
+
this.#udp = new UdpSocket();
|
|
30
|
+
this.#setupListeners();
|
|
31
|
+
}
|
|
32
|
+
#setupListeners() {
|
|
33
|
+
this.#gateway.on('ready', (payload) => {
|
|
34
|
+
this.#udp.connect(payload.ssrc, payload.ip, payload.port).then(() => {
|
|
35
|
+
this.#gateway.sendSelectProtocol(this.#udp.ip, this.#udp.port, this.encryption ?? 'xsalsa20_poly1305_lite_rtpsize');
|
|
36
|
+
}).catch((err) => this.emit('error', err));
|
|
37
|
+
});
|
|
38
|
+
this.#gateway.on('sessionDescription', (payload) => {
|
|
39
|
+
this.#udp.setSecretKey(payload.secret_key);
|
|
40
|
+
this.udpInfo = {
|
|
41
|
+
ssrc: this.#gateway.ssrc,
|
|
42
|
+
ip: this.#gateway.ip,
|
|
43
|
+
port: this.#gateway.port,
|
|
44
|
+
secretKey: payload.secret_key,
|
|
45
|
+
};
|
|
46
|
+
this.#encryption = new AudioEncryption(payload.mode, payload.secret_key, this.#gateway.ssrc);
|
|
47
|
+
this.state = { status: 'connected', reason: null, code: null };
|
|
48
|
+
this.emit('stateChange', { status: 'connecting' }, this.state);
|
|
49
|
+
this.emit('ready');
|
|
50
|
+
});
|
|
51
|
+
this.#gateway.on('close', (code, reason) => {
|
|
52
|
+
this.state = { status: 'disconnected', reason, code };
|
|
53
|
+
this.emit('stateChange', { status: 'connected' }, this.state);
|
|
54
|
+
});
|
|
55
|
+
this.#gateway.on('error', (err) => this.emit('error', err));
|
|
56
|
+
this.#gateway.on('heartbeatAck', (d) => {
|
|
57
|
+
if (d.t)
|
|
58
|
+
this.ping = Date.now() - d.t;
|
|
59
|
+
});
|
|
60
|
+
this.#udp.on('error', (err) => this.emit('error', err));
|
|
61
|
+
}
|
|
62
|
+
voiceStateUpdate(obj) {
|
|
63
|
+
this.#sessionId = obj.sessionId ?? obj.session_id ?? this.#sessionId;
|
|
64
|
+
this.#gateway.voiceStateUpdate(this.#sessionId);
|
|
65
|
+
}
|
|
66
|
+
voiceServerUpdate(obj) {
|
|
67
|
+
this.#token = obj.token;
|
|
68
|
+
this.#endpoint = obj.endpoint;
|
|
69
|
+
this.#gateway.voiceServerUpdate(obj.token, obj.endpoint, obj.channelId ?? obj.channel_id);
|
|
70
|
+
this.#gateway.connect();
|
|
71
|
+
}
|
|
72
|
+
sendAudioFrame(frame) {
|
|
73
|
+
if (this.#destroyed)
|
|
74
|
+
return;
|
|
75
|
+
if (this.#encryption) {
|
|
76
|
+
const encrypted = this.#encryption.encrypt(frame);
|
|
77
|
+
if (!encrypted)
|
|
78
|
+
return; // silence frame
|
|
79
|
+
this.#udp.send(encrypted);
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
this.#udp.send(frame);
|
|
83
|
+
}
|
|
84
|
+
this.statistics.packetsSent++;
|
|
85
|
+
this.statistics.packetsExpected++;
|
|
86
|
+
}
|
|
87
|
+
setSpeaking(value) {
|
|
88
|
+
this.#gateway.setSpeaking(value);
|
|
89
|
+
if (value > 0) {
|
|
90
|
+
this.playerState = { status: 'playing', reason: null };
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
this.playerState = { status: 'stopped', reason: null };
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
destroy() {
|
|
97
|
+
this.#destroyed = true;
|
|
98
|
+
this.state = { status: 'destroyed', reason: 'destroyed', code: null };
|
|
99
|
+
this.#gateway.close();
|
|
100
|
+
this.#udp.close();
|
|
101
|
+
this.emit('stateChange', { status: 'connected' }, this.state);
|
|
102
|
+
this.removeAllListeners();
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
export function joinVoiceChannel(options) {
|
|
106
|
+
return new VoiceConnection(options);
|
|
107
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { EncryptionMode } from './types.js';
|
|
2
|
+
declare const SILENCE_FRAME: Buffer<ArrayBuffer>;
|
|
3
|
+
export interface EncryptedPacket {
|
|
4
|
+
header: Buffer;
|
|
5
|
+
nonce: Buffer;
|
|
6
|
+
encrypted: Buffer;
|
|
7
|
+
}
|
|
8
|
+
export declare class AudioEncryption {
|
|
9
|
+
#private;
|
|
10
|
+
constructor(mode: EncryptionMode, secretKey: Buffer, ssrc: number);
|
|
11
|
+
get sequence(): number;
|
|
12
|
+
get timestamp(): number;
|
|
13
|
+
get ssrc(): number;
|
|
14
|
+
encrypt(frame: Buffer): Buffer | null;
|
|
15
|
+
}
|
|
16
|
+
export { SILENCE_FRAME };
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { createCipheriv } from 'node:crypto';
|
|
2
|
+
import nacl from 'tweetnacl';
|
|
3
|
+
const SILENCE_FRAME = Buffer.from([0xf8, 0xff, 0xfe]);
|
|
4
|
+
const VERSION = 0x80;
|
|
5
|
+
const TYPE = 0x78;
|
|
6
|
+
export class AudioEncryption {
|
|
7
|
+
#mode;
|
|
8
|
+
#secretKey;
|
|
9
|
+
#sequence = 0;
|
|
10
|
+
#timestamp = 0;
|
|
11
|
+
#ssrc;
|
|
12
|
+
#nonceBuffer;
|
|
13
|
+
constructor(mode, secretKey, ssrc) {
|
|
14
|
+
this.#mode = mode;
|
|
15
|
+
this.#secretKey = secretKey;
|
|
16
|
+
this.#ssrc = ssrc;
|
|
17
|
+
this.#nonceBuffer = Buffer.alloc(mode === 'aead_xchacha20_poly1305_rtpsize' ? 24 : 12);
|
|
18
|
+
}
|
|
19
|
+
get sequence() { return this.#sequence; }
|
|
20
|
+
get timestamp() { return this.#timestamp; }
|
|
21
|
+
get ssrc() { return this.#ssrc; }
|
|
22
|
+
encrypt(frame) {
|
|
23
|
+
if (frame.equals(SILENCE_FRAME))
|
|
24
|
+
return null;
|
|
25
|
+
const header = this.#buildHeader();
|
|
26
|
+
const seq = this.#sequence;
|
|
27
|
+
const ts = this.#timestamp;
|
|
28
|
+
this.#sequence = (this.#sequence + 1) & 0xffff;
|
|
29
|
+
this.#timestamp = (this.#timestamp + 960) >>> 0;
|
|
30
|
+
const nonce = this.#makeNonce(seq);
|
|
31
|
+
let encrypted;
|
|
32
|
+
switch (this.#mode) {
|
|
33
|
+
case 'aead_aes256_gcm_rtpsize': {
|
|
34
|
+
encrypted = this.#encryptAes256Gcm(header, frame, nonce);
|
|
35
|
+
break;
|
|
36
|
+
}
|
|
37
|
+
case 'aead_xchacha20_poly1305_rtpsize': {
|
|
38
|
+
encrypted = this.#encryptXChaCha20(header, frame, nonce);
|
|
39
|
+
break;
|
|
40
|
+
}
|
|
41
|
+
default: {
|
|
42
|
+
encrypted = this.#encryptXsalsa20(header, frame, nonce);
|
|
43
|
+
break;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return Buffer.concat([header, encrypted, nonce.subarray(0, this.#nonceLen())]);
|
|
47
|
+
}
|
|
48
|
+
#nonceLen() {
|
|
49
|
+
return this.#mode === 'aead_aes256_gcm_rtpsize' ? 4
|
|
50
|
+
: this.#mode === 'aead_xchacha20_poly1305_rtpsize' ? 24
|
|
51
|
+
: 4;
|
|
52
|
+
}
|
|
53
|
+
#buildHeader() {
|
|
54
|
+
const h = Buffer.alloc(12);
|
|
55
|
+
h[0] = VERSION;
|
|
56
|
+
h[1] = TYPE;
|
|
57
|
+
h.writeUInt16BE(this.#sequence, 2);
|
|
58
|
+
h.writeUInt32BE(this.#timestamp, 4);
|
|
59
|
+
h.writeUInt32BE(this.#ssrc, 8);
|
|
60
|
+
return h;
|
|
61
|
+
}
|
|
62
|
+
#makeNonce(seq) {
|
|
63
|
+
const nonce = Buffer.alloc(this.#nonceBuffer.length);
|
|
64
|
+
if (this.#mode === 'aead_xchacha20_poly1305_rtpsize') {
|
|
65
|
+
nonce.writeUInt32BE(seq, 0);
|
|
66
|
+
this.#nonceBuffer.copy(nonce, 4, 0, 20);
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
nonce.writeUInt32BE(seq, 0);
|
|
70
|
+
}
|
|
71
|
+
return nonce;
|
|
72
|
+
}
|
|
73
|
+
#encryptAes256Gcm(header, frame, nonce) {
|
|
74
|
+
const cipher = createCipheriv('aes-256-gcm', this.#secretKey, nonce, { authTagLength: 16 });
|
|
75
|
+
cipher.setAAD(header);
|
|
76
|
+
const enc = cipher.update(frame);
|
|
77
|
+
cipher.final();
|
|
78
|
+
return Buffer.concat([enc, cipher.getAuthTag()]);
|
|
79
|
+
}
|
|
80
|
+
#encryptXChaCha20(header, frame, nonce) {
|
|
81
|
+
try {
|
|
82
|
+
const sodium = require('libsodium-wrappers');
|
|
83
|
+
const enc = sodium.crypto_aead_xchacha20poly1305_ietf_encrypt(frame, header, null, nonce, this.#secretKey);
|
|
84
|
+
return Buffer.from(enc);
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return this.#encryptXsalsa20(header, frame, nonce);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
#encryptXsalsa20(header, frame, nonce) {
|
|
91
|
+
const fullNonce = Buffer.alloc(24);
|
|
92
|
+
nonce.copy(fullNonce);
|
|
93
|
+
const enc = nacl.secretbox(frame, fullNonce, this.#secretKey);
|
|
94
|
+
return Buffer.from(enc);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
export { SILENCE_FRAME };
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
export interface VoiceServerPayload {
|
|
3
|
+
token: string;
|
|
4
|
+
endpoint: string;
|
|
5
|
+
channel_id?: string;
|
|
6
|
+
channelId?: string;
|
|
7
|
+
}
|
|
8
|
+
export interface ReadyPayload {
|
|
9
|
+
ssrc: number;
|
|
10
|
+
ip: string;
|
|
11
|
+
port: number;
|
|
12
|
+
modes: string[];
|
|
13
|
+
}
|
|
14
|
+
export interface SessionDescriptionPayload {
|
|
15
|
+
mode: string;
|
|
16
|
+
secret_key: number[];
|
|
17
|
+
dave_protocol_version?: number;
|
|
18
|
+
}
|
|
19
|
+
export declare class VoiceGateway extends EventEmitter {
|
|
20
|
+
#private;
|
|
21
|
+
get connected(): boolean;
|
|
22
|
+
get ssrc(): number;
|
|
23
|
+
get ip(): string;
|
|
24
|
+
get port(): number;
|
|
25
|
+
get modes(): string[];
|
|
26
|
+
get secretKey(): Buffer<ArrayBufferLike> | null;
|
|
27
|
+
get daveProtocolVersion(): number;
|
|
28
|
+
constructor(guildId: string, userId: string);
|
|
29
|
+
voiceStateUpdate(sessionId: string): void;
|
|
30
|
+
voiceServerUpdate(token: string, endpoint: string, channelId?: string): void;
|
|
31
|
+
connect(): void;
|
|
32
|
+
sendBinary(data: Buffer): void;
|
|
33
|
+
sendSelectProtocol(address: string, port: number, mode: string): void;
|
|
34
|
+
setSpeaking(value: number, delay?: number): void;
|
|
35
|
+
close(): void;
|
|
36
|
+
}
|
package/dist/gateway.js
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import WebSocket from 'ws';
|
|
2
|
+
import { EventEmitter } from 'node:events';
|
|
3
|
+
const DISCORD_VOICE_VERSION = 8;
|
|
4
|
+
const DISCORD_VOICE_URL_TEMPLATE = 'wss://{endpoint}/?v={v}';
|
|
5
|
+
export class VoiceGateway extends EventEmitter {
|
|
6
|
+
#ws = null;
|
|
7
|
+
#guildId;
|
|
8
|
+
#userId;
|
|
9
|
+
#sessionId = '';
|
|
10
|
+
#token = '';
|
|
11
|
+
#endpoint = '';
|
|
12
|
+
#channelId = '';
|
|
13
|
+
#heartbeatInterval = 0;
|
|
14
|
+
#heartbeatTimer = null;
|
|
15
|
+
#ssrc = 0;
|
|
16
|
+
#ip = '';
|
|
17
|
+
#port = 0;
|
|
18
|
+
#modes = [];
|
|
19
|
+
#secretKey = null;
|
|
20
|
+
#connected = false;
|
|
21
|
+
#daveProtocolVersion = 0;
|
|
22
|
+
get connected() { return this.#connected; }
|
|
23
|
+
get ssrc() { return this.#ssrc; }
|
|
24
|
+
get ip() { return this.#ip; }
|
|
25
|
+
get port() { return this.#port; }
|
|
26
|
+
get modes() { return this.#modes; }
|
|
27
|
+
get secretKey() { return this.#secretKey; }
|
|
28
|
+
get daveProtocolVersion() { return this.#daveProtocolVersion; }
|
|
29
|
+
constructor(guildId, userId) {
|
|
30
|
+
super();
|
|
31
|
+
this.#guildId = guildId;
|
|
32
|
+
this.#userId = userId;
|
|
33
|
+
}
|
|
34
|
+
voiceStateUpdate(sessionId) {
|
|
35
|
+
this.#sessionId = sessionId;
|
|
36
|
+
}
|
|
37
|
+
voiceServerUpdate(token, endpoint, channelId) {
|
|
38
|
+
this.#token = token;
|
|
39
|
+
this.#endpoint = endpoint.replace(/^wss:\/\//, '').replace(/\/\?v=\d+$/, '');
|
|
40
|
+
if (channelId)
|
|
41
|
+
this.#channelId = channelId;
|
|
42
|
+
}
|
|
43
|
+
connect() {
|
|
44
|
+
const url = DISCORD_VOICE_URL_TEMPLATE
|
|
45
|
+
.replace('{endpoint}', this.#endpoint)
|
|
46
|
+
.replace('{v}', String(DISCORD_VOICE_VERSION));
|
|
47
|
+
this.#ws = new WebSocket(url);
|
|
48
|
+
this.#ws.on('open', () => this.#onOpen());
|
|
49
|
+
this.#ws.on('message', (data) => this.#onMessage(data));
|
|
50
|
+
this.#ws.on('close', (code, reason) => this.#onClose(code, reason));
|
|
51
|
+
this.#ws.on('error', (err) => this.emit('error', err));
|
|
52
|
+
}
|
|
53
|
+
#onOpen() {
|
|
54
|
+
this.#sendOp(0, {
|
|
55
|
+
server_id: this.#guildId,
|
|
56
|
+
user_id: this.#userId,
|
|
57
|
+
session_id: this.#sessionId,
|
|
58
|
+
token: this.#token,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
#onMessage(data) {
|
|
62
|
+
try {
|
|
63
|
+
const json = JSON.parse(data.toString());
|
|
64
|
+
this.#handleOp(json.op, json.d);
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
// binary op (DAVE)
|
|
68
|
+
if (data.length > 0) {
|
|
69
|
+
this.emit('binary', data);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
#handleOp(op, d) {
|
|
74
|
+
switch (op) {
|
|
75
|
+
case 2: { // Ready
|
|
76
|
+
const payload = d;
|
|
77
|
+
this.#ssrc = payload.ssrc;
|
|
78
|
+
this.#ip = payload.ip;
|
|
79
|
+
this.#port = payload.port;
|
|
80
|
+
this.#modes = payload.modes;
|
|
81
|
+
this.emit('ready', payload);
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
case 4: { // Session Description
|
|
85
|
+
const payload = d;
|
|
86
|
+
this.#secretKey = Buffer.from(payload.secret_key);
|
|
87
|
+
this.#daveProtocolVersion = payload.dave_protocol_version ?? 0;
|
|
88
|
+
this.#connected = true;
|
|
89
|
+
this.emit('sessionDescription', payload);
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
case 5: { // Speaking
|
|
93
|
+
this.emit('speaking', d);
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
case 6: { // Heartbeat ACK
|
|
97
|
+
this.emit('heartbeatAck', d);
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
case 8: { // Hello
|
|
101
|
+
this.#heartbeatInterval = d.heartbeat_interval;
|
|
102
|
+
this.#startHeartbeat();
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
case 9: { // Resumed
|
|
106
|
+
this.emit('resumed');
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
#onClose(code, reason) {
|
|
112
|
+
this.#connected = false;
|
|
113
|
+
this.#stopHeartbeat();
|
|
114
|
+
this.emit('close', code, reason?.toString());
|
|
115
|
+
}
|
|
116
|
+
#startHeartbeat() {
|
|
117
|
+
let seq = 0;
|
|
118
|
+
this.#heartbeatTimer = setInterval(() => {
|
|
119
|
+
this.#sendOp(3, { t: Date.now(), seq_ack: seq });
|
|
120
|
+
}, this.#heartbeatInterval);
|
|
121
|
+
}
|
|
122
|
+
#stopHeartbeat() {
|
|
123
|
+
if (this.#heartbeatTimer)
|
|
124
|
+
clearInterval(this.#heartbeatTimer);
|
|
125
|
+
}
|
|
126
|
+
#sendOp(op, data) {
|
|
127
|
+
if (this.#ws?.readyState === WebSocket.OPEN) {
|
|
128
|
+
this.#ws.send(JSON.stringify({ op, d: data }));
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
sendBinary(data) {
|
|
132
|
+
if (this.#ws?.readyState === WebSocket.OPEN) {
|
|
133
|
+
this.#ws.send(data);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
sendSelectProtocol(address, port, mode) {
|
|
137
|
+
this.#sendOp(1, {
|
|
138
|
+
protocol: 'udp',
|
|
139
|
+
data: { address, port, mode },
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
setSpeaking(value, delay = 0) {
|
|
143
|
+
this.#sendOp(5, { speaking: value, delay, ssrc: this.#ssrc });
|
|
144
|
+
}
|
|
145
|
+
close() {
|
|
146
|
+
this.#connected = false;
|
|
147
|
+
this.#stopHeartbeat();
|
|
148
|
+
if (this.#ws) {
|
|
149
|
+
this.#ws.close();
|
|
150
|
+
this.#ws = null;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { VoiceConnection, joinVoiceChannel } from './connection.js';
|
|
2
|
+
export { VoiceGateway } from './gateway.js';
|
|
3
|
+
export { UdpSocket } from './udp.js';
|
|
4
|
+
export { AudioEncryption, SILENCE_FRAME } from './encryption.js';
|
|
5
|
+
export type { VoiceConnectOptions, ConnectionState, PlayerState, UdpInfo, ConnectionStatistics, VoiceConnection as IVoiceConnection, JoinVoiceOptions, EncryptionMode, } from './types.js';
|
package/dist/index.js
ADDED
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
export interface VoiceConnectOptions {
|
|
2
|
+
guildId: string;
|
|
3
|
+
userId: string;
|
|
4
|
+
sessionId: string;
|
|
5
|
+
token: string;
|
|
6
|
+
endpoint: string;
|
|
7
|
+
channelId: string;
|
|
8
|
+
}
|
|
9
|
+
export interface ConnectionState {
|
|
10
|
+
status: 'connecting' | 'connected' | 'disconnected' | 'destroyed';
|
|
11
|
+
reason: string | null;
|
|
12
|
+
code: number | null;
|
|
13
|
+
}
|
|
14
|
+
export interface PlayerState {
|
|
15
|
+
status: 'playing' | 'paused' | 'stopped';
|
|
16
|
+
reason: string | null;
|
|
17
|
+
}
|
|
18
|
+
export interface UdpInfo {
|
|
19
|
+
ssrc: number;
|
|
20
|
+
ip: string;
|
|
21
|
+
port: number;
|
|
22
|
+
secretKey: Buffer | null;
|
|
23
|
+
}
|
|
24
|
+
export interface ConnectionStatistics {
|
|
25
|
+
packetsSent: number;
|
|
26
|
+
packetsLost: number;
|
|
27
|
+
packetsExpected: number;
|
|
28
|
+
}
|
|
29
|
+
export interface VoiceConnection {
|
|
30
|
+
guildId: string;
|
|
31
|
+
userId: string;
|
|
32
|
+
channelId: string;
|
|
33
|
+
encryption: string | null;
|
|
34
|
+
state: ConnectionState;
|
|
35
|
+
playerState: PlayerState;
|
|
36
|
+
ping: number;
|
|
37
|
+
statistics: ConnectionStatistics;
|
|
38
|
+
udpInfo: UdpInfo | null;
|
|
39
|
+
voiceStateUpdate(obj: {
|
|
40
|
+
session_id?: string;
|
|
41
|
+
sessionId?: string;
|
|
42
|
+
}): void;
|
|
43
|
+
voiceServerUpdate(obj: {
|
|
44
|
+
token: string;
|
|
45
|
+
endpoint: string;
|
|
46
|
+
channel_id?: string;
|
|
47
|
+
channelId?: string;
|
|
48
|
+
}): void;
|
|
49
|
+
sendAudioFrame(frame: Buffer): void;
|
|
50
|
+
setSpeaking(value: number): void;
|
|
51
|
+
destroy(): void;
|
|
52
|
+
on(event: string | symbol, listener: (...args: any[]) => void): this;
|
|
53
|
+
removeAllListeners(event?: string | symbol): this;
|
|
54
|
+
}
|
|
55
|
+
export interface JoinVoiceOptions {
|
|
56
|
+
guildId: string;
|
|
57
|
+
userId: string;
|
|
58
|
+
channelId: string;
|
|
59
|
+
encryption?: string | null;
|
|
60
|
+
}
|
|
61
|
+
export type EncryptionMode = 'aead_aes256_gcm_rtpsize' | 'aead_xchacha20_poly1305_rtpsize' | 'xsalsa20_poly1305_lite_rtpsize' | 'xsalsa20_poly1305_suffix_rtpsize' | 'normal';
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/udp.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
export declare class UdpSocket extends EventEmitter {
|
|
3
|
+
#private;
|
|
4
|
+
get connected(): boolean;
|
|
5
|
+
get ip(): string;
|
|
6
|
+
get port(): number;
|
|
7
|
+
get ssrc(): number;
|
|
8
|
+
get secretKey(): Buffer<ArrayBufferLike> | null;
|
|
9
|
+
connect(ssrc: number, ip: string, port: number): Promise<void>;
|
|
10
|
+
setSecretKey(key: Buffer): void;
|
|
11
|
+
send(packet: Buffer): void;
|
|
12
|
+
close(): void;
|
|
13
|
+
}
|
package/dist/udp.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { createSocket } from 'node:dgram';
|
|
2
|
+
import { EventEmitter } from 'node:events';
|
|
3
|
+
const KEEPALIVE_INTERVAL = 10_000;
|
|
4
|
+
const IP_DISCOVERY_PACKET = Buffer.alloc(74);
|
|
5
|
+
export class UdpSocket extends EventEmitter {
|
|
6
|
+
#socket = null;
|
|
7
|
+
#ip = '';
|
|
8
|
+
#port = 0;
|
|
9
|
+
#ssrc = 0;
|
|
10
|
+
#secretKey = null;
|
|
11
|
+
#keepaliveTimer = null;
|
|
12
|
+
#connected = false;
|
|
13
|
+
get connected() { return this.#connected; }
|
|
14
|
+
get ip() { return this.#ip; }
|
|
15
|
+
get port() { return this.#port; }
|
|
16
|
+
get ssrc() { return this.#ssrc; }
|
|
17
|
+
get secretKey() { return this.#secretKey; }
|
|
18
|
+
async connect(ssrc, ip, port) {
|
|
19
|
+
this.#ssrc = ssrc;
|
|
20
|
+
this.#socket = createSocket('udp4');
|
|
21
|
+
this.#socket.on('message', (msg) => this.#onMessage(msg));
|
|
22
|
+
this.#socket.on('error', (err) => this.emit('error', err));
|
|
23
|
+
await this.#discoverIp(ip, port);
|
|
24
|
+
this.#startKeepalive();
|
|
25
|
+
this.#connected = true;
|
|
26
|
+
}
|
|
27
|
+
#onMessage(msg) {
|
|
28
|
+
if (msg.length === 74) {
|
|
29
|
+
const type = msg.readUInt16BE(0);
|
|
30
|
+
if (type === 2) {
|
|
31
|
+
const ip = `${msg.readUInt8(4)}.${msg.readUInt8(5)}.${msg.readUInt8(6)}.${msg.readUInt8(7)}`;
|
|
32
|
+
const port = msg.readUInt16BE(8);
|
|
33
|
+
this.emit('ipDiscovery', ip, port);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
#discoverIp(ip, port) {
|
|
38
|
+
return new Promise((resolve, reject) => {
|
|
39
|
+
IP_DISCOVERY_PACKET.writeUInt16BE(1, 0);
|
|
40
|
+
IP_DISCOVERY_PACKET.writeUInt16BE(70, 2);
|
|
41
|
+
IP_DISCOVERY_PACKET.writeUInt32BE(this.#ssrc, 4);
|
|
42
|
+
const timeout = setTimeout(() => reject(new Error('IP discovery timed out')), 5000);
|
|
43
|
+
this.#socket.send(IP_DISCOVERY_PACKET, port, ip);
|
|
44
|
+
this.once('ipDiscovery', (discIp, discPort) => {
|
|
45
|
+
clearTimeout(timeout);
|
|
46
|
+
this.#ip = discIp;
|
|
47
|
+
this.#port = discPort || port;
|
|
48
|
+
resolve();
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
setSecretKey(key) {
|
|
53
|
+
this.#secretKey = key;
|
|
54
|
+
}
|
|
55
|
+
send(packet) {
|
|
56
|
+
if (!this.#socket || !this.#ip)
|
|
57
|
+
return;
|
|
58
|
+
this.#socket.send(packet, this.#port, this.#ip);
|
|
59
|
+
}
|
|
60
|
+
#startKeepalive() {
|
|
61
|
+
const packet = Buffer.alloc(74);
|
|
62
|
+
packet.writeUInt16BE(1, 0);
|
|
63
|
+
packet.writeUInt16BE(70, 2);
|
|
64
|
+
packet.writeUInt32BE(this.#ssrc, 4);
|
|
65
|
+
this.#keepaliveTimer = setInterval(() => {
|
|
66
|
+
if (this.#socket) {
|
|
67
|
+
this.#socket.send(packet, this.#port, this.#ip);
|
|
68
|
+
}
|
|
69
|
+
}, KEEPALIVE_INTERVAL);
|
|
70
|
+
}
|
|
71
|
+
close() {
|
|
72
|
+
if (this.#keepaliveTimer)
|
|
73
|
+
clearInterval(this.#keepaliveTimer);
|
|
74
|
+
if (this.#socket) {
|
|
75
|
+
this.#socket.close();
|
|
76
|
+
this.#socket = null;
|
|
77
|
+
}
|
|
78
|
+
this.#connected = false;
|
|
79
|
+
}
|
|
80
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@sonata-sdk/voice",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Discord voice connection library โ WebSocket gateway, UDP, RTP, encryption, DAVE/MLS",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"import": "./dist/index.js",
|
|
12
|
+
"types": "./dist/index.d.ts"
|
|
13
|
+
},
|
|
14
|
+
"./gateway": {
|
|
15
|
+
"import": "./dist/gateway.js",
|
|
16
|
+
"types": "./dist/gateway.d.ts"
|
|
17
|
+
},
|
|
18
|
+
"./udp": {
|
|
19
|
+
"import": "./dist/udp.js",
|
|
20
|
+
"types": "./dist/udp.d.ts"
|
|
21
|
+
},
|
|
22
|
+
"./encryption": {
|
|
23
|
+
"import": "./dist/encryption.js",
|
|
24
|
+
"types": "./dist/encryption.d.ts"
|
|
25
|
+
},
|
|
26
|
+
"./connection": {
|
|
27
|
+
"import": "./dist/connection.js",
|
|
28
|
+
"types": "./dist/connection.d.ts"
|
|
29
|
+
},
|
|
30
|
+
"./types": {
|
|
31
|
+
"import": "./dist/types.js",
|
|
32
|
+
"types": "./dist/types.d.ts"
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"files": [
|
|
36
|
+
"dist",
|
|
37
|
+
"LICENSE",
|
|
38
|
+
"README.md"
|
|
39
|
+
],
|
|
40
|
+
"scripts": {
|
|
41
|
+
"build": "tsc",
|
|
42
|
+
"prepublishOnly": "npm run build"
|
|
43
|
+
},
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"opusscript": "^0.1.1",
|
|
46
|
+
"tweetnacl": "^1.0.3"
|
|
47
|
+
},
|
|
48
|
+
"optionalDependencies": {
|
|
49
|
+
"@snazzah/davey": "^0.1.11",
|
|
50
|
+
"libsodium-wrappers": "^0.7.15"
|
|
51
|
+
},
|
|
52
|
+
"devDependencies": {
|
|
53
|
+
"@types/node": "^25.9.1",
|
|
54
|
+
"@types/ws": "^8.18.1",
|
|
55
|
+
"typescript": "^5.4.0",
|
|
56
|
+
"ws": "^8.21.0"
|
|
57
|
+
}
|
|
58
|
+
}
|