@lox-audioserver/node-slimproto 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/.devcontainer/devcontainer.json +22 -0
- package/.github/workflows/publish.yml +33 -0
- package/README.md +51 -0
- package/dist/client.d.ts +78 -0
- package/dist/client.js +652 -0
- package/dist/constants.d.ts +6 -0
- package/dist/constants.js +6 -0
- package/dist/discovery.d.ts +10 -0
- package/dist/discovery.js +104 -0
- package/dist/errors.d.ts +3 -0
- package/dist/errors.js +6 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +8 -0
- package/dist/models.d.ts +119 -0
- package/dist/models.js +168 -0
- package/dist/server.d.ts +27 -0
- package/dist/server.js +109 -0
- package/dist/util.d.ts +12 -0
- package/dist/util.js +98 -0
- package/dist/volume.d.ts +15 -0
- package/dist/volume.js +59 -0
- package/package.json +28 -0
- package/src/client.ts +780 -0
- package/src/constants.ts +7 -0
- package/src/discovery.ts +121 -0
- package/src/errors.ts +6 -0
- package/src/index.ts +8 -0
- package/src/models.ts +202 -0
- package/src/server.ts +142 -0
- package/src/util.ts +104 -0
- package/src/volume.ts +64 -0
- package/tsconfig.json +15 -0
package/src/constants.ts
ADDED
package/src/discovery.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import dgram from 'dgram';
|
|
2
|
+
|
|
3
|
+
type OrderedMap = [string, string | null][];
|
|
4
|
+
|
|
5
|
+
function parseTlvDiscoveryRequest(payload: Buffer): OrderedMap {
|
|
6
|
+
const data = payload.toString('utf-8', 1); // drop leading 'e'
|
|
7
|
+
const result: OrderedMap = [];
|
|
8
|
+
let idx = 0;
|
|
9
|
+
while (idx <= data.length - 5) {
|
|
10
|
+
const key = data.slice(idx, idx + 4);
|
|
11
|
+
const len = data.charCodeAt(idx + 4);
|
|
12
|
+
idx += 5;
|
|
13
|
+
const value = len > 0 ? data.slice(idx, idx + len) : null;
|
|
14
|
+
idx += len;
|
|
15
|
+
result.push([key, value]);
|
|
16
|
+
}
|
|
17
|
+
return result;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function buildTlvResponse(
|
|
21
|
+
requestData: OrderedMap,
|
|
22
|
+
opts: { name: string; ipAddress: string; cliPort?: number | null; cliPortJson?: number | null; uuid: string },
|
|
23
|
+
): OrderedMap {
|
|
24
|
+
const response: OrderedMap = [];
|
|
25
|
+
for (const [key, val] of requestData) {
|
|
26
|
+
switch (key) {
|
|
27
|
+
case 'NAME':
|
|
28
|
+
response.push([key, opts.name]);
|
|
29
|
+
break;
|
|
30
|
+
case 'IPAD':
|
|
31
|
+
response.push([key, opts.ipAddress]);
|
|
32
|
+
break;
|
|
33
|
+
case 'JSON':
|
|
34
|
+
if (opts.cliPortJson != null) response.push([key, String(opts.cliPortJson)]);
|
|
35
|
+
break;
|
|
36
|
+
case 'CLIP':
|
|
37
|
+
if (opts.cliPort != null) response.push([key, String(opts.cliPort)]);
|
|
38
|
+
break;
|
|
39
|
+
case 'VERS':
|
|
40
|
+
response.push([key, '7.999.999']);
|
|
41
|
+
break;
|
|
42
|
+
case 'UUID':
|
|
43
|
+
response.push([key, opts.uuid]);
|
|
44
|
+
break;
|
|
45
|
+
default:
|
|
46
|
+
response.push([key, val]);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return response;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function encodeTlvResponse(responseData: OrderedMap): Buffer {
|
|
53
|
+
const parts: string[] = ['E']; // response prefix
|
|
54
|
+
for (const [key, value] of responseData) {
|
|
55
|
+
const val = value ?? '';
|
|
56
|
+
const truncated = val.length > 255 ? val.slice(0, 255) : val;
|
|
57
|
+
parts.push(key, String.fromCharCode(truncated.length), truncated);
|
|
58
|
+
}
|
|
59
|
+
return Buffer.from(parts.join(''), 'utf-8');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function encodeLegacyDiscovery(ipAddress: string): Buffer {
|
|
63
|
+
const hostname = ipAddress.slice(0, 16).padEnd(16, '\u0000');
|
|
64
|
+
const buf = Buffer.alloc(17);
|
|
65
|
+
buf.write('D', 0, 'ascii');
|
|
66
|
+
buf.write(hostname, 1, 'binary');
|
|
67
|
+
return buf;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface DiscoveryOptions {
|
|
71
|
+
ipAddress: string;
|
|
72
|
+
controlPort: number;
|
|
73
|
+
cliPort?: number | null;
|
|
74
|
+
cliPortJson?: number | null;
|
|
75
|
+
name?: string;
|
|
76
|
+
uuid?: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function startDiscovery(opts: DiscoveryOptions): dgram.Socket {
|
|
80
|
+
const socket = dgram.createSocket({ type: 'udp4', reuseAddr: true });
|
|
81
|
+
const name = opts.name ?? 'Slimproto';
|
|
82
|
+
const uuid = opts.uuid ?? 'slimproto';
|
|
83
|
+
|
|
84
|
+
socket.on('listening', () => {
|
|
85
|
+
try {
|
|
86
|
+
socket.addMembership('239.255.255.250');
|
|
87
|
+
} catch (err) {
|
|
88
|
+
// eslint-disable-next-line no-console
|
|
89
|
+
console.warn('Failed to join discovery multicast group', err);
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
socket.on('message', (msg, rinfo) => {
|
|
94
|
+
try {
|
|
95
|
+
if (msg.length === 0) return;
|
|
96
|
+
if (msg[0] === 0x65) {
|
|
97
|
+
const requestData = parseTlvDiscoveryRequest(msg);
|
|
98
|
+
const responseData = buildTlvResponse(requestData, {
|
|
99
|
+
name,
|
|
100
|
+
ipAddress: opts.ipAddress,
|
|
101
|
+
cliPort: opts.cliPort,
|
|
102
|
+
cliPortJson: opts.cliPortJson,
|
|
103
|
+
uuid,
|
|
104
|
+
});
|
|
105
|
+
const payload = encodeTlvResponse(responseData);
|
|
106
|
+
socket.send(payload, rinfo.port, rinfo.address);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
if (msg[0] === 0x64) {
|
|
110
|
+
const payload = encodeLegacyDiscovery(opts.ipAddress);
|
|
111
|
+
socket.send(payload, rinfo.port, rinfo.address);
|
|
112
|
+
}
|
|
113
|
+
} catch (err) {
|
|
114
|
+
// eslint-disable-next-line no-console
|
|
115
|
+
console.error('Error handling discovery message from', rinfo.address, err);
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
socket.bind(opts.controlPort, '0.0.0.0');
|
|
120
|
+
return socket;
|
|
121
|
+
}
|
package/src/errors.ts
ADDED
package/src/index.ts
ADDED
package/src/models.ts
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
export enum EventType {
|
|
2
|
+
PLAYER_UPDATED = 'player_updated',
|
|
3
|
+
PLAYER_HEARTBEAT = 'player_heartbeat',
|
|
4
|
+
PLAYER_CONNECTED = 'player_connected',
|
|
5
|
+
PLAYER_DISCONNECTED = 'player_disconnected',
|
|
6
|
+
PLAYER_NAME_RECEIVED = 'player_name_received',
|
|
7
|
+
PLAYER_DISPLAY_RESOLUTION = 'player_display_resolution',
|
|
8
|
+
PLAYER_DECODER_READY = 'player_decoder_ready',
|
|
9
|
+
PLAYER_DECODER_ERROR = 'player_decoder_error',
|
|
10
|
+
PLAYER_OUTPUT_UNDERRUN = 'player_output_underrun',
|
|
11
|
+
PLAYER_BUFFER_READY = 'player_buffer_ready',
|
|
12
|
+
PLAYER_CLI_EVENT = 'player_cli_event',
|
|
13
|
+
PLAYER_BTN_EVENT = 'player_btn_event',
|
|
14
|
+
PLAYER_PRESETS_UPDATED = 'player_presets_updated',
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface SlimEvent<T = unknown> {
|
|
18
|
+
type: EventType;
|
|
19
|
+
playerId: string;
|
|
20
|
+
data?: T;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const DEVICE_TYPE: Record<number, string> = {
|
|
24
|
+
2: 'squeezebox',
|
|
25
|
+
3: 'softsqueeze',
|
|
26
|
+
4: 'squeezebox2',
|
|
27
|
+
5: 'transporter',
|
|
28
|
+
6: 'softsqueeze3',
|
|
29
|
+
7: 'receiver',
|
|
30
|
+
8: 'squeezeslave',
|
|
31
|
+
9: 'controller',
|
|
32
|
+
10: 'boom',
|
|
33
|
+
11: 'softboom',
|
|
34
|
+
12: 'squeezeplay',
|
|
35
|
+
100: 'squeezeesp32',
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export enum PlayerState {
|
|
39
|
+
PLAYING = 'playing',
|
|
40
|
+
STOPPED = 'stopped',
|
|
41
|
+
PAUSED = 'paused',
|
|
42
|
+
BUFFERING = 'buffering',
|
|
43
|
+
BUFFER_READY = 'buffer_ready',
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export enum TransitionType {
|
|
47
|
+
NONE = '0',
|
|
48
|
+
CROSSFADE = '1',
|
|
49
|
+
FADE_IN = '2',
|
|
50
|
+
FADE_OUT = '3',
|
|
51
|
+
FADE_IN_OUT = '4',
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export enum VisualisationType {
|
|
55
|
+
NONE = 'none',
|
|
56
|
+
SPECTRUM_ANALYZER = 'spectrum_analyzer',
|
|
57
|
+
VU_METER_ANALOG = 'vu_meter_analog',
|
|
58
|
+
VU_METER_DIGITAL = 'vu_meter_digital',
|
|
59
|
+
WAVEFORM = 'waveform',
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export enum RemoteCode {
|
|
63
|
+
SLEEP = 1988737095,
|
|
64
|
+
POWER = 1988706495,
|
|
65
|
+
REWIND = 1988739135,
|
|
66
|
+
PAUSE = 1988698335,
|
|
67
|
+
FORWARD = 1988730975,
|
|
68
|
+
ADD = 1988714655,
|
|
69
|
+
PLAY = 1988694255,
|
|
70
|
+
UP = 1988747295,
|
|
71
|
+
DOWN = 1988735055,
|
|
72
|
+
LEFT = 1988726895,
|
|
73
|
+
RIGHT = 1988743215,
|
|
74
|
+
VOLUME_UP = 1988722815,
|
|
75
|
+
VOLUME_DOWN = 1988690175,
|
|
76
|
+
NUM_1 = 1988751375,
|
|
77
|
+
NUM_2 = 1988692215,
|
|
78
|
+
NUM_3 = 1988724855,
|
|
79
|
+
NUM_4 = 1988708535,
|
|
80
|
+
NUM_5 = 1988741175,
|
|
81
|
+
NUM_6 = 1988700375,
|
|
82
|
+
NUM_7 = 1988733015,
|
|
83
|
+
NUM_8 = 1988716695,
|
|
84
|
+
NUM_9 = 1988749335,
|
|
85
|
+
NUM_0 = 1988728935,
|
|
86
|
+
FAVORITES = 1988696295,
|
|
87
|
+
SEARCH = 1988712615,
|
|
88
|
+
BROWSE = 1988718735,
|
|
89
|
+
SHUFFLE = 1988745255,
|
|
90
|
+
REPEAT = 1988704455,
|
|
91
|
+
NOW_PLAYING = 1988720775,
|
|
92
|
+
SIZE = 1988753415,
|
|
93
|
+
BRIGHTNESS = 1988691195,
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export enum ButtonCode {
|
|
97
|
+
POWER = 65546,
|
|
98
|
+
PRESET_1 = 131104,
|
|
99
|
+
PRESET_2 = 131105,
|
|
100
|
+
PRESET_3 = 131106,
|
|
101
|
+
PRESET_4 = 131107,
|
|
102
|
+
PRESET_5 = 131108,
|
|
103
|
+
PRESET_6 = 131109,
|
|
104
|
+
BACK = 131085,
|
|
105
|
+
PLAY = 131090,
|
|
106
|
+
ADD = 131091,
|
|
107
|
+
UP = 131083,
|
|
108
|
+
OK = 131086,
|
|
109
|
+
REWIND = 131088,
|
|
110
|
+
PAUSE = 131095,
|
|
111
|
+
FORWARD = 131101,
|
|
112
|
+
VOLUME_DOWN = 131081,
|
|
113
|
+
POWER_RELEASE = 131082,
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export const PCM_SAMPLE_SIZE: Record<number, Buffer> = {
|
|
117
|
+
8: Buffer.from('0'),
|
|
118
|
+
16: Buffer.from('1'),
|
|
119
|
+
20: Buffer.from('2'),
|
|
120
|
+
32: Buffer.from('3'),
|
|
121
|
+
24: Buffer.from('4'),
|
|
122
|
+
0: Buffer.from('?'),
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
export const PCM_SAMPLE_RATE: Record<number, Buffer> = {
|
|
126
|
+
11000: Buffer.from('0'),
|
|
127
|
+
22000: Buffer.from('1'),
|
|
128
|
+
44100: Buffer.from('3'),
|
|
129
|
+
48000: Buffer.from('4'),
|
|
130
|
+
8000: Buffer.from('5'),
|
|
131
|
+
12000: Buffer.from('6'),
|
|
132
|
+
16000: Buffer.from('7'),
|
|
133
|
+
24000: Buffer.from('8'),
|
|
134
|
+
88200: Buffer.from(':'),
|
|
135
|
+
96000: Buffer.from('9'),
|
|
136
|
+
176400: Buffer.from(';'),
|
|
137
|
+
192000: Buffer.from('<'),
|
|
138
|
+
352800: Buffer.from('='),
|
|
139
|
+
384000: Buffer.from('>'),
|
|
140
|
+
0: Buffer.from('?'),
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
export const CODEC_MAPPING: Record<string, string> = {
|
|
144
|
+
'audio/mp3': 'mp3',
|
|
145
|
+
'audio/mpeg': 'mp3',
|
|
146
|
+
'audio/flac': 'flc',
|
|
147
|
+
'audio/x-flac': 'flc',
|
|
148
|
+
'audio/wma': 'wma',
|
|
149
|
+
'audio/ogg': 'ogg',
|
|
150
|
+
'audio/oga': 'ogg',
|
|
151
|
+
'audio/aac': 'aac',
|
|
152
|
+
'audio/aacp': 'aac',
|
|
153
|
+
'audio/alac': 'alc',
|
|
154
|
+
'audio/wav': 'pcm',
|
|
155
|
+
'audio/x-wav': 'pcm',
|
|
156
|
+
'audio/dsf': 'dsf',
|
|
157
|
+
'audio/pcm,': 'pcm',
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
export const FORMAT_BYTE: Record<string, Buffer> = {
|
|
161
|
+
pcm: Buffer.from('p'),
|
|
162
|
+
mp3: Buffer.from('m'),
|
|
163
|
+
flc: Buffer.from('f'),
|
|
164
|
+
wma: Buffer.from('w'),
|
|
165
|
+
ogg: Buffer.from('o'),
|
|
166
|
+
aac: Buffer.from('a'),
|
|
167
|
+
alc: Buffer.from('l'),
|
|
168
|
+
dsf: Buffer.from('p'),
|
|
169
|
+
dff: Buffer.from('p'),
|
|
170
|
+
aif: Buffer.from('p'),
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
export const PLAYMODE_MAP: Record<PlayerState, string> = {
|
|
174
|
+
[PlayerState.STOPPED]: 'stop',
|
|
175
|
+
[PlayerState.PLAYING]: 'play',
|
|
176
|
+
[PlayerState.BUFFER_READY]: 'play',
|
|
177
|
+
[PlayerState.BUFFERING]: 'play',
|
|
178
|
+
[PlayerState.PAUSED]: 'pause',
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
export interface MediaMetadata {
|
|
182
|
+
item_id?: string;
|
|
183
|
+
artist?: string;
|
|
184
|
+
album?: string;
|
|
185
|
+
title?: string;
|
|
186
|
+
image_url?: string;
|
|
187
|
+
duration?: number;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export interface MediaDetails {
|
|
191
|
+
url: string;
|
|
192
|
+
mimeType?: string;
|
|
193
|
+
metadata?: MediaMetadata;
|
|
194
|
+
transition?: TransitionType;
|
|
195
|
+
transitionDuration?: number;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export interface Preset {
|
|
199
|
+
uri: string;
|
|
200
|
+
text: string;
|
|
201
|
+
icon: string;
|
|
202
|
+
}
|
package/src/server.ts
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { EventEmitter } from 'events';
|
|
2
|
+
import net from 'net';
|
|
3
|
+
import { startDiscovery } from './discovery.js';
|
|
4
|
+
import { SLIMPROTO_PORT } from './constants.js';
|
|
5
|
+
import { EventType, SlimEvent } from './models.js';
|
|
6
|
+
import { getHostname, getIp } from './util.js';
|
|
7
|
+
import { SlimClient } from './client.js';
|
|
8
|
+
|
|
9
|
+
export interface SlimServerOptions {
|
|
10
|
+
cliPort?: number | null;
|
|
11
|
+
cliPortJson?: number | null;
|
|
12
|
+
ipAddress?: string | null;
|
|
13
|
+
name?: string | null;
|
|
14
|
+
controlPort?: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
type Subscription = {
|
|
18
|
+
callback: (event: SlimEvent) => void | Promise<void>;
|
|
19
|
+
eventFilter: EventType[] | null;
|
|
20
|
+
playerFilter: string[] | null;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export class SlimServer extends EventEmitter {
|
|
24
|
+
readonly options: SlimServerOptions;
|
|
25
|
+
|
|
26
|
+
private server?: net.Server;
|
|
27
|
+
private discovery?: import('dgram').Socket;
|
|
28
|
+
private playersMap = new Map<string, SlimClient>();
|
|
29
|
+
private subscriptions: Subscription[] = [];
|
|
30
|
+
|
|
31
|
+
constructor(options: SlimServerOptions = {}) {
|
|
32
|
+
super();
|
|
33
|
+
this.options = options;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
get players(): SlimClient[] {
|
|
37
|
+
return Array.from(this.playersMap.values());
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
getPlayer(playerId: string): SlimClient | undefined {
|
|
41
|
+
return this.playersMap.get(playerId);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async start(): Promise<void> {
|
|
45
|
+
const ipAddress = this.options.ipAddress ?? (await getIp());
|
|
46
|
+
const name = this.options.name ?? getHostname();
|
|
47
|
+
const controlPort = this.options.controlPort ?? SLIMPROTO_PORT;
|
|
48
|
+
this.server = net.createServer((socket) => this.registerPlayer(socket));
|
|
49
|
+
await new Promise<void>((resolve, reject) => {
|
|
50
|
+
this.server?.once('error', reject);
|
|
51
|
+
this.server?.listen(controlPort, '0.0.0.0', () => resolve());
|
|
52
|
+
});
|
|
53
|
+
this.discovery = startDiscovery({
|
|
54
|
+
ipAddress,
|
|
55
|
+
controlPort,
|
|
56
|
+
cliPort: this.options.cliPort ?? null,
|
|
57
|
+
cliPortJson: this.options.cliPortJson ?? null,
|
|
58
|
+
name,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async stop(): Promise<void> {
|
|
63
|
+
for (const client of this.playersMap.values()) {
|
|
64
|
+
client.disconnect();
|
|
65
|
+
}
|
|
66
|
+
this.playersMap.clear();
|
|
67
|
+
if (this.server) {
|
|
68
|
+
await new Promise<void>((resolve) => this.server?.close(() => resolve()));
|
|
69
|
+
this.server = undefined;
|
|
70
|
+
}
|
|
71
|
+
this.discovery?.close();
|
|
72
|
+
this.discovery = undefined;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
subscribe(
|
|
76
|
+
cb: (event: SlimEvent) => void | Promise<void>,
|
|
77
|
+
eventFilter?: EventType | EventType[] | null,
|
|
78
|
+
playerFilter?: string | string[] | null,
|
|
79
|
+
): () => void {
|
|
80
|
+
const eventList =
|
|
81
|
+
eventFilter == null ? null : Array.isArray(eventFilter) ? eventFilter : [eventFilter];
|
|
82
|
+
const playerList =
|
|
83
|
+
playerFilter == null ? null : Array.isArray(playerFilter) ? playerFilter : [playerFilter];
|
|
84
|
+
const subscription: Subscription = { callback: cb, eventFilter: eventList, playerFilter: playerList };
|
|
85
|
+
this.subscriptions.push(subscription);
|
|
86
|
+
return () => {
|
|
87
|
+
const idx = this.subscriptions.indexOf(subscription);
|
|
88
|
+
if (idx >= 0) this.subscriptions.splice(idx, 1);
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
private registerPlayer(socket: net.Socket): void {
|
|
93
|
+
const client = new SlimClient(socket, (player, eventType, data) => {
|
|
94
|
+
this.handleClientEvent(player, eventType, data);
|
|
95
|
+
});
|
|
96
|
+
socket.on('close', () => this.handleDisconnect(client));
|
|
97
|
+
socket.on('error', () => this.handleDisconnect(client));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
private handleDisconnect(client: SlimClient): void {
|
|
101
|
+
if (client.playerId) {
|
|
102
|
+
this.playersMap.delete(client.playerId);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
private handleClientEvent(player: SlimClient, eventType: EventType, data?: unknown): void {
|
|
107
|
+
const playerId = player.playerId;
|
|
108
|
+
|
|
109
|
+
if (eventType === EventType.PLAYER_CONNECTED) {
|
|
110
|
+
const existing = this.playersMap.get(playerId);
|
|
111
|
+
if (existing && existing.connected) {
|
|
112
|
+
player.disconnect();
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (existing) {
|
|
116
|
+
existing.disconnect();
|
|
117
|
+
}
|
|
118
|
+
this.playersMap.set(playerId, player);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (eventType === EventType.PLAYER_DISCONNECTED) {
|
|
122
|
+
if (playerId) {
|
|
123
|
+
this.playersMap.delete(playerId);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
this.forwardEvent(playerId, eventType, data);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
private forwardEvent(playerId: string, eventType: EventType, data?: unknown): void {
|
|
131
|
+
const event: SlimEvent = { type: eventType, playerId, data };
|
|
132
|
+
this.emit(eventType, event);
|
|
133
|
+
for (const sub of this.subscriptions) {
|
|
134
|
+
if (sub.playerFilter && playerId && !sub.playerFilter.includes(playerId)) continue;
|
|
135
|
+
if (sub.eventFilter && !sub.eventFilter.includes(eventType)) continue;
|
|
136
|
+
Promise.resolve(sub.callback(event)).catch((err) =>
|
|
137
|
+
// eslint-disable-next-line no-console
|
|
138
|
+
console.error('Error in subscriber', err),
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
package/src/util.ts
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import dgram from 'dgram';
|
|
2
|
+
import dns from 'dns/promises';
|
|
3
|
+
import net from 'net';
|
|
4
|
+
import os from 'os';
|
|
5
|
+
import { FALLBACK_CODECS } from './constants.js';
|
|
6
|
+
|
|
7
|
+
export async function getIp(): Promise<string> {
|
|
8
|
+
const socket = dgram.createSocket('udp4');
|
|
9
|
+
return new Promise((resolve) => {
|
|
10
|
+
socket.connect(1, '10.255.255.255', () => {
|
|
11
|
+
const address = socket.address();
|
|
12
|
+
socket.close();
|
|
13
|
+
if (typeof address === 'object') {
|
|
14
|
+
resolve(address.address);
|
|
15
|
+
} else {
|
|
16
|
+
resolve('127.0.0.1');
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
socket.on('error', () => {
|
|
20
|
+
socket.close();
|
|
21
|
+
resolve('127.0.0.1');
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function getHostname(): string {
|
|
27
|
+
return os.hostname();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function selectFreePort(rangeStart: number, rangeEnd: number): Promise<number> {
|
|
31
|
+
const isPortInUse = (port: number): Promise<boolean> =>
|
|
32
|
+
new Promise((resolve) => {
|
|
33
|
+
const tester = net.createServer();
|
|
34
|
+
tester.once('error', () => resolve(true));
|
|
35
|
+
tester.once('listening', () => tester.close(() => resolve(false)));
|
|
36
|
+
tester.listen(port, '0.0.0.0');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
for (let port = rangeStart; port < rangeEnd; port += 1) {
|
|
40
|
+
// eslint-disable-next-line no-await-in-loop
|
|
41
|
+
const inUse = await isPortInUse(port);
|
|
42
|
+
if (!inUse) {
|
|
43
|
+
return port;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
throw new Error('No free port available');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function parseCapabilities(heloData: Buffer): Record<string, unknown> {
|
|
50
|
+
const params: Record<string, unknown> = {};
|
|
51
|
+
try {
|
|
52
|
+
const info = heloData.subarray(36).toString();
|
|
53
|
+
const pairs = info.replace(/,/g, '&').split('&');
|
|
54
|
+
for (const pair of pairs) {
|
|
55
|
+
if (!pair) continue;
|
|
56
|
+
const [key, value] = pair.split('=');
|
|
57
|
+
if (key) {
|
|
58
|
+
params[key] = value ?? '';
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
params.SupportedCodecs =
|
|
62
|
+
['alc', 'aac', 'ogg', 'ogf', 'flc', 'aif', 'pcm', 'mp3'].filter((codec) => info.includes(codec)) ||
|
|
63
|
+
FALLBACK_CODECS;
|
|
64
|
+
} catch (err) {
|
|
65
|
+
// eslint-disable-next-line no-console
|
|
66
|
+
console.error('Failed to parse capabilities', err);
|
|
67
|
+
}
|
|
68
|
+
return params;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function parseHeaders(respData: Buffer): Record<string, string> {
|
|
72
|
+
const result: Record<string, string> = {};
|
|
73
|
+
const lines = respData.toString().split('\r\n').slice(1);
|
|
74
|
+
for (const line of lines) {
|
|
75
|
+
const [key, ...rest] = line.split(': ');
|
|
76
|
+
if (!key || rest.length === 0) continue;
|
|
77
|
+
result[key.toLowerCase()] = rest.join(': ');
|
|
78
|
+
}
|
|
79
|
+
return result;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function parseStatus(respData: Buffer): { version: string; statusCode: number; statusText: string } {
|
|
83
|
+
const [statusLine] = respData.toString().split('\r\n');
|
|
84
|
+
if (!statusLine) {
|
|
85
|
+
return { version: 'HTTP/1.0', statusCode: 200, statusText: '' };
|
|
86
|
+
}
|
|
87
|
+
const [version, code, ...rest] = statusLine.split(' ');
|
|
88
|
+
return {
|
|
89
|
+
version,
|
|
90
|
+
statusCode: Number(code ?? 200),
|
|
91
|
+
statusText: rest.join(' '),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function lookupHost(host: string): Promise<string> {
|
|
96
|
+
const result = await dns.lookup(host);
|
|
97
|
+
return result.address;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function ipToInt(ipAddress: string): number {
|
|
101
|
+
return ipAddress
|
|
102
|
+
.split('.')
|
|
103
|
+
.reduce((acc, octet) => (acc << 8) + Number(octet), 0) >>> 0;
|
|
104
|
+
}
|
package/src/volume.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
export class SlimProtoVolume {
|
|
2
|
+
minimum = 0;
|
|
3
|
+
maximum = 100;
|
|
4
|
+
step = 1;
|
|
5
|
+
|
|
6
|
+
private static readonly oldMap: number[] = [
|
|
7
|
+
0, 1, 1, 1, 2, 2, 2, 3, 3, 4, 5, 5, 6, 6, 7, 8, 9, 9, 10, 11, 12, 13, 14, 15,
|
|
8
|
+
16, 16, 17, 18, 19, 20, 22, 23, 24, 25, 26, 27, 28, 29, 30, 32, 33, 34, 35, 37,
|
|
9
|
+
38, 39, 40, 42, 43, 44, 46, 47, 48, 50, 51, 53, 54, 56, 57, 59, 60, 61, 63, 65,
|
|
10
|
+
66, 68, 69, 71, 72, 74, 75, 77, 79, 80, 82, 84, 85, 87, 89, 90, 92, 94, 96, 97,
|
|
11
|
+
99, 101, 103, 104, 106, 108, 110, 112, 113, 115, 117, 119, 121, 123, 125, 127,
|
|
12
|
+
128,
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
private readonly totalVolumeRange = -50; // dB
|
|
16
|
+
private readonly stepPoint = -1;
|
|
17
|
+
private readonly stepFraction = 1;
|
|
18
|
+
|
|
19
|
+
volume = 50;
|
|
20
|
+
|
|
21
|
+
increment(): void {
|
|
22
|
+
this.volume = Math.min(this.volume + this.step, this.maximum);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
decrement(): void {
|
|
26
|
+
this.volume = Math.max(this.volume - this.step, this.minimum);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
oldGain(): number {
|
|
30
|
+
if (this.volume <= 0) return 0;
|
|
31
|
+
return SlimProtoVolume.oldMap[this.volume];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
decibels(): number {
|
|
35
|
+
const stepDb = this.totalVolumeRange * this.stepFraction;
|
|
36
|
+
const maxVolumeDb = 0;
|
|
37
|
+
const slopeHigh = maxVolumeDb - stepDb / (100 - this.stepPoint);
|
|
38
|
+
const slopeLow = stepDb - this.totalVolumeRange / (this.stepPoint - 0.0);
|
|
39
|
+
const x2 = this.volume;
|
|
40
|
+
let m: number;
|
|
41
|
+
let x1: number;
|
|
42
|
+
let y1: number;
|
|
43
|
+
if (x2 > this.stepPoint) {
|
|
44
|
+
m = slopeHigh;
|
|
45
|
+
x1 = 100;
|
|
46
|
+
y1 = maxVolumeDb;
|
|
47
|
+
} else {
|
|
48
|
+
m = slopeLow;
|
|
49
|
+
x1 = 0;
|
|
50
|
+
y1 = this.totalVolumeRange;
|
|
51
|
+
}
|
|
52
|
+
return m * (x2 - x1) + y1;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
newGain(): number {
|
|
56
|
+
if (this.volume <= 0) return 0;
|
|
57
|
+
const decibel = this.decibels();
|
|
58
|
+
const floatmult = 10 ** (decibel / 20.0);
|
|
59
|
+
if (decibel >= -30 && decibel <= 0) {
|
|
60
|
+
return Math.trunc(floatmult * (1 << 8) + 0.5) * (1 << 8);
|
|
61
|
+
}
|
|
62
|
+
return Math.trunc(floatmult * (1 << 16) + 0.5);
|
|
63
|
+
}
|
|
64
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ES2022",
|
|
5
|
+
"moduleResolution": "node",
|
|
6
|
+
"declaration": true,
|
|
7
|
+
"outDir": "dist",
|
|
8
|
+
"rootDir": "src",
|
|
9
|
+
"strict": true,
|
|
10
|
+
"esModuleInterop": true,
|
|
11
|
+
"forceConsistentCasingInFileNames": true,
|
|
12
|
+
"skipLibCheck": true
|
|
13
|
+
},
|
|
14
|
+
"include": ["src/**/*"]
|
|
15
|
+
}
|