@lox-audioserver/node-airplay-sender 0.4.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 +93 -0
- package/dist/core/ap2_test.d.ts +1 -0
- package/dist/core/ap2_test.js +8 -0
- package/dist/core/atv.d.ts +16 -0
- package/dist/core/atv.js +215 -0
- package/dist/core/atvAuthenticator.d.ts +30 -0
- package/dist/core/atvAuthenticator.js +134 -0
- package/dist/core/audioOut.d.ts +43 -0
- package/dist/core/audioOut.js +220 -0
- package/dist/core/deviceAirtunes.d.ts +76 -0
- package/dist/core/deviceAirtunes.js +536 -0
- package/dist/core/devices.d.ts +50 -0
- package/dist/core/devices.js +221 -0
- package/dist/core/index.d.ts +56 -0
- package/dist/core/index.js +144 -0
- package/dist/core/rtsp.d.ts +12 -0
- package/dist/core/rtsp.js +1678 -0
- package/dist/core/srp.d.ts +14 -0
- package/dist/core/srp.js +128 -0
- package/dist/core/udpServers.d.ts +44 -0
- package/dist/core/udpServers.js +244 -0
- package/dist/esm/core/ap2_test.js +8 -0
- package/dist/esm/core/atv.js +215 -0
- package/dist/esm/core/atvAuthenticator.js +134 -0
- package/dist/esm/core/audioOut.js +220 -0
- package/dist/esm/core/deviceAirtunes.js +536 -0
- package/dist/esm/core/devices.js +221 -0
- package/dist/esm/core/index.js +144 -0
- package/dist/esm/core/rtsp.js +1678 -0
- package/dist/esm/core/srp.js +128 -0
- package/dist/esm/core/udpServers.js +244 -0
- package/dist/esm/homekit/credentials.js +109 -0
- package/dist/esm/homekit/encryption.js +82 -0
- package/dist/esm/homekit/number.js +47 -0
- package/dist/esm/homekit/tlv.js +97 -0
- package/dist/esm/index.js +310 -0
- package/dist/esm/package.json +1 -0
- package/dist/esm/utils/alac.js +62 -0
- package/dist/esm/utils/alacEncoder.js +34 -0
- package/dist/esm/utils/circularBuffer.js +132 -0
- package/dist/esm/utils/config.js +49 -0
- package/dist/esm/utils/http.js +148 -0
- package/dist/esm/utils/ntp.js +56 -0
- package/dist/esm/utils/numUtil.js +17 -0
- package/dist/esm/utils/packetPool.js +52 -0
- package/dist/esm/utils/util.js +9 -0
- package/dist/homekit/credentials.d.ts +31 -0
- package/dist/homekit/credentials.js +109 -0
- package/dist/homekit/encryption.d.ts +12 -0
- package/dist/homekit/encryption.js +82 -0
- package/dist/homekit/number.d.ts +7 -0
- package/dist/homekit/number.js +47 -0
- package/dist/homekit/tlv.d.ts +25 -0
- package/dist/homekit/tlv.js +97 -0
- package/dist/index.d.ts +121 -0
- package/dist/index.js +310 -0
- package/dist/utils/alac.d.ts +9 -0
- package/dist/utils/alac.js +62 -0
- package/dist/utils/alacEncoder.d.ts +14 -0
- package/dist/utils/alacEncoder.js +34 -0
- package/dist/utils/circularBuffer.d.ts +32 -0
- package/dist/utils/circularBuffer.js +132 -0
- package/dist/utils/config.d.ts +42 -0
- package/dist/utils/config.js +49 -0
- package/dist/utils/http.d.ts +19 -0
- package/dist/utils/http.js +148 -0
- package/dist/utils/ntp.d.ts +21 -0
- package/dist/utils/ntp.js +56 -0
- package/dist/utils/numUtil.d.ts +5 -0
- package/dist/utils/numUtil.js +17 -0
- package/dist/utils/packetPool.d.ts +25 -0
- package/dist/utils/packetPool.js +52 -0
- package/dist/utils/util.d.ts +2 -0
- package/dist/utils/util.js +9 -0
- package/package.json +71 -0
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
const node_events_1 = require("node:events");
|
|
7
|
+
const packetPool_1 = __importDefault(require("./packetPool"));
|
|
8
|
+
const WAITING = 0;
|
|
9
|
+
const FILLING = 1;
|
|
10
|
+
const NORMAL = 2;
|
|
11
|
+
const DRAINING = 3;
|
|
12
|
+
const ENDING = 4;
|
|
13
|
+
const ENDED = 5;
|
|
14
|
+
/**
|
|
15
|
+
* Fixed-size circular buffer that smooths incoming PCM/ALAC chunks into fixed packet sizes.
|
|
16
|
+
* Emits status changes for buffering/playing/drain/end to drive UI + sync.
|
|
17
|
+
*/
|
|
18
|
+
class CircularBuffer extends node_events_1.EventEmitter {
|
|
19
|
+
packetPool;
|
|
20
|
+
maxSize;
|
|
21
|
+
packetSize;
|
|
22
|
+
writable = true;
|
|
23
|
+
muted = false;
|
|
24
|
+
buffers = [];
|
|
25
|
+
currentSize = 0;
|
|
26
|
+
status = WAITING;
|
|
27
|
+
hadUnderrun = false;
|
|
28
|
+
constructor(packetsInBuffer, packetSize) {
|
|
29
|
+
super();
|
|
30
|
+
this.packetPool = new packetPool_1.default(packetSize);
|
|
31
|
+
this.maxSize = packetsInBuffer * packetSize;
|
|
32
|
+
this.packetSize = packetSize;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Write a PCM/ALAC chunk into the buffer.
|
|
36
|
+
* Returns false when the buffer is full so upstream can throttle.
|
|
37
|
+
*/
|
|
38
|
+
write(chunk) {
|
|
39
|
+
this.buffers.push(chunk);
|
|
40
|
+
this.currentSize += chunk.length;
|
|
41
|
+
if (this.status === ENDING || this.status === ENDED) {
|
|
42
|
+
throw new Error('Cannot write in buffer after closing it');
|
|
43
|
+
}
|
|
44
|
+
if (this.status === WAITING) {
|
|
45
|
+
this.emit('status', 'buffering');
|
|
46
|
+
this.status = FILLING;
|
|
47
|
+
}
|
|
48
|
+
if (this.status === FILLING && this.currentSize > this.maxSize / 2) {
|
|
49
|
+
this.status = NORMAL;
|
|
50
|
+
this.emit('status', 'playing');
|
|
51
|
+
}
|
|
52
|
+
if (this.currentSize >= this.maxSize) {
|
|
53
|
+
this.status = DRAINING;
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Read the next fixed-size packet, zero-filling gaps to preserve timing.
|
|
60
|
+
*/
|
|
61
|
+
readPacket() {
|
|
62
|
+
const packet = this.packetPool.getPacket();
|
|
63
|
+
if (this.status !== ENDING &&
|
|
64
|
+
this.status !== ENDED &&
|
|
65
|
+
(this.status === FILLING || this.currentSize < this.packetSize)) {
|
|
66
|
+
packet.pcm.fill(0);
|
|
67
|
+
if (!this.hadUnderrun) {
|
|
68
|
+
this.hadUnderrun = true;
|
|
69
|
+
this.emit('underrun');
|
|
70
|
+
}
|
|
71
|
+
if (this.status !== FILLING && this.status !== WAITING) {
|
|
72
|
+
this.status = FILLING;
|
|
73
|
+
this.emit('status', 'buffering');
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
let offset = 0;
|
|
78
|
+
let remaining = this.packetSize;
|
|
79
|
+
while (remaining > 0) {
|
|
80
|
+
if (this.buffers.length === 0) {
|
|
81
|
+
packet.pcm.fill(0, offset);
|
|
82
|
+
remaining = 0;
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
const first = this.buffers[0];
|
|
86
|
+
if (first.length <= remaining) {
|
|
87
|
+
first.copy(packet.pcm, offset);
|
|
88
|
+
offset += first.length;
|
|
89
|
+
remaining -= first.length;
|
|
90
|
+
this.buffers.shift();
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
first.copy(packet.pcm, offset, 0, remaining);
|
|
94
|
+
this.buffers[0] = first.slice(remaining);
|
|
95
|
+
offset += remaining;
|
|
96
|
+
remaining = 0;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
this.currentSize -= this.packetSize;
|
|
100
|
+
if (this.hadUnderrun && this.currentSize >= this.packetSize) {
|
|
101
|
+
this.hadUnderrun = false;
|
|
102
|
+
}
|
|
103
|
+
if (this.status === ENDING && this.currentSize <= 0) {
|
|
104
|
+
this.status = ENDED;
|
|
105
|
+
this.currentSize = 0;
|
|
106
|
+
this.emit('status', 'end');
|
|
107
|
+
}
|
|
108
|
+
if (this.status === DRAINING && this.currentSize < this.maxSize / 2) {
|
|
109
|
+
this.status = NORMAL;
|
|
110
|
+
this.emit('drain');
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (this.muted) {
|
|
114
|
+
packet.pcm.fill(0);
|
|
115
|
+
}
|
|
116
|
+
return packet;
|
|
117
|
+
}
|
|
118
|
+
/** Mark the buffer as ending; drains then emits `end`. */
|
|
119
|
+
end() {
|
|
120
|
+
if (this.status === FILLING) {
|
|
121
|
+
this.emit('status', 'playing');
|
|
122
|
+
}
|
|
123
|
+
this.status = ENDING;
|
|
124
|
+
}
|
|
125
|
+
/** Clear internal buffers and state. */
|
|
126
|
+
reset() {
|
|
127
|
+
this.buffers = [];
|
|
128
|
+
this.currentSize = 0;
|
|
129
|
+
this.status = WAITING;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
exports.default = CircularBuffer;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export interface AirplayConfig {
|
|
2
|
+
user_agent: string;
|
|
3
|
+
udp_default_port: number;
|
|
4
|
+
frames_per_packet: number;
|
|
5
|
+
channels_per_frame: number;
|
|
6
|
+
bits_per_channel: number;
|
|
7
|
+
pcm_packet_size: number;
|
|
8
|
+
alac_packet_size: number;
|
|
9
|
+
packet_size: number;
|
|
10
|
+
packets_in_buffer: number;
|
|
11
|
+
coreaudio_min_level: number;
|
|
12
|
+
coreaudio_check_period: number;
|
|
13
|
+
coreaudio_preload: number;
|
|
14
|
+
sampling_rate: number;
|
|
15
|
+
sync_period: number;
|
|
16
|
+
stream_latency: number;
|
|
17
|
+
rtsp_timeout: number;
|
|
18
|
+
rtsp_heartbeat: number;
|
|
19
|
+
rtsp_retry_attempts: number;
|
|
20
|
+
rtsp_retry_base_ms: number;
|
|
21
|
+
rtsp_retry_max_ms: number;
|
|
22
|
+
rtsp_retry_jitter_ms: number;
|
|
23
|
+
control_sync_base_delay_ms: number;
|
|
24
|
+
control_sync_jitter_ms: number;
|
|
25
|
+
use_monotonic_clock: boolean;
|
|
26
|
+
jump_forward_enabled: boolean;
|
|
27
|
+
jump_forward_threshold_ms: number;
|
|
28
|
+
jump_forward_lead_ms: number;
|
|
29
|
+
device_magic: number;
|
|
30
|
+
resend_buffer_size: number;
|
|
31
|
+
send_rtcp_sr: boolean;
|
|
32
|
+
send_rtcp_rr: boolean;
|
|
33
|
+
send_rtcp_xr: boolean;
|
|
34
|
+
underrun_mute_ms: number;
|
|
35
|
+
debug_dump: boolean;
|
|
36
|
+
ntp_epoch: number;
|
|
37
|
+
iv_base64: string;
|
|
38
|
+
rsa_aeskey_base64: string;
|
|
39
|
+
}
|
|
40
|
+
export declare const config: AirplayConfig;
|
|
41
|
+
export declare function applyConfig(overrides: Partial<AirplayConfig>): AirplayConfig;
|
|
42
|
+
export default config;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.config = void 0;
|
|
4
|
+
exports.applyConfig = applyConfig;
|
|
5
|
+
const numUtil_1 = require("./numUtil");
|
|
6
|
+
exports.config = {
|
|
7
|
+
user_agent: 'iTunes/11.3.1 (Windows; Microsoft Windows 10 x64 (Build 19044); x64) (dt:2)',
|
|
8
|
+
udp_default_port: 54621, // preferred starting port in AirTunes v2
|
|
9
|
+
frames_per_packet: 352, // samples per frames in ALAC packets
|
|
10
|
+
channels_per_frame: 2, // always stereo in AirTunes v2
|
|
11
|
+
bits_per_channel: 16, // -> 2 bytes per channel
|
|
12
|
+
pcm_packet_size: 352 * 2 * 2, // frames*channels*bytes
|
|
13
|
+
alac_packet_size: 352 * 2 * 2 + 8, // pcm payload + alac header/footer
|
|
14
|
+
packet_size: 352 * 2 * 2, // active packet size (depends on input codec)
|
|
15
|
+
packets_in_buffer: 260, // ~2.1s of audio (matches MA's ~2000ms buffer)
|
|
16
|
+
coreaudio_min_level: 5, // if CoreAudio's internal buffer drops too much, inject some silence to raise it
|
|
17
|
+
coreaudio_check_period: 2000, // CoreAudio buffer level check period
|
|
18
|
+
coreaudio_preload: 352 * 2 * 2 * 50, // ~0.5s of silence pushed to CoreAudio to avoid draining AudioQueue
|
|
19
|
+
sampling_rate: 44100, // fixed by AirTunes v2
|
|
20
|
+
sync_period: 0, // UDP sync packets are sent to all AirTunes devices regularly (0 = derive from sample rate)
|
|
21
|
+
stream_latency: 200, // audio UDP packets are flushed in bursts periodically
|
|
22
|
+
rtsp_timeout: 2147483647, // RTSP servers are considered gone if no reply is received before the timeout (legacy default)
|
|
23
|
+
rtsp_heartbeat: 15000, // some RTSP (like HomePod) servers requires heartbeat.
|
|
24
|
+
rtsp_retry_attempts: 3,
|
|
25
|
+
rtsp_retry_base_ms: 300,
|
|
26
|
+
rtsp_retry_max_ms: 4000,
|
|
27
|
+
rtsp_retry_jitter_ms: 150,
|
|
28
|
+
control_sync_base_delay_ms: 2,
|
|
29
|
+
control_sync_jitter_ms: 3,
|
|
30
|
+
use_monotonic_clock: true,
|
|
31
|
+
jump_forward_enabled: false,
|
|
32
|
+
jump_forward_threshold_ms: 180,
|
|
33
|
+
jump_forward_lead_ms: 220,
|
|
34
|
+
device_magic: (0, numUtil_1.randomInt)(9),
|
|
35
|
+
resend_buffer_size: 1000,
|
|
36
|
+
send_rtcp_sr: true,
|
|
37
|
+
send_rtcp_rr: true,
|
|
38
|
+
send_rtcp_xr: false,
|
|
39
|
+
underrun_mute_ms: 50,
|
|
40
|
+
debug_dump: false,
|
|
41
|
+
ntp_epoch: 0x83aa7e80,
|
|
42
|
+
iv_base64: 'ePRBLI0XN5ArFaaz7ncNZw',
|
|
43
|
+
rsa_aeskey_base64: 'VjVbxWcmYgbBbhwBNlCh3K0CMNtWoB844BuiHGUJT51zQS7SDpMnlbBIobsKbfEJ3SCgWHRXjYWf7VQWRYtEcfx7ejA8xDIk5PSBYTvXP5dU2QoGrSBv0leDS6uxlEWuxBq3lIxCxpWO2YswHYKJBt06Uz9P2Fq2hDUwl3qOQ8oXb0OateTKtfXEwHJMprkhsJsGDrIc5W5NJFMAo6zCiM9bGSDeH2nvTlyW6bfI/Q0v0cDGUNeY3ut6fsoafRkfpCwYId+bg3diJh+uzw5htHDyZ2sN+BFYHzEfo8iv4KDxzeya9llqg6fRNQ8d5YjpvTnoeEQ9ye9ivjkBjcAfVw',
|
|
44
|
+
};
|
|
45
|
+
function applyConfig(overrides) {
|
|
46
|
+
Object.assign(exports.config, overrides);
|
|
47
|
+
return exports.config;
|
|
48
|
+
}
|
|
49
|
+
exports.default = exports.config;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal HTTP/1.1 client for plain TCP connections (no TLS).
|
|
3
|
+
* Used for AirPlay control endpoints where lightweight parsing is sufficient.
|
|
4
|
+
*/
|
|
5
|
+
type Headers = Record<string, string>;
|
|
6
|
+
export type MessageObject = {
|
|
7
|
+
method?: string;
|
|
8
|
+
path?: string;
|
|
9
|
+
statusCode?: number;
|
|
10
|
+
headers: Headers;
|
|
11
|
+
body?: Buffer;
|
|
12
|
+
};
|
|
13
|
+
export interface HttpClientApi {
|
|
14
|
+
connect(host: string, port?: number): Promise<void>;
|
|
15
|
+
request(method: string, path: string, headers?: Headers, body?: Buffer): Promise<MessageObject | null>;
|
|
16
|
+
close(): void;
|
|
17
|
+
}
|
|
18
|
+
declare const createHttpClient: () => HttpClientApi;
|
|
19
|
+
export default createHttpClient;
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
const node_net_1 = __importDefault(require("node:net"));
|
|
7
|
+
const HttpMessage = (parseStartLine, writeStartLine) => {
|
|
8
|
+
const instance = {
|
|
9
|
+
parse: () => ({ headers: {} }),
|
|
10
|
+
write: () => Buffer.alloc(0),
|
|
11
|
+
};
|
|
12
|
+
instance.parse = (buffer) => {
|
|
13
|
+
const messageObject = { headers: {} };
|
|
14
|
+
// ...
|
|
15
|
+
let bodyIndex = buffer.indexOf('\r\n\r\n');
|
|
16
|
+
let headerString = buffer.slice(0, bodyIndex).toString();
|
|
17
|
+
let body = buffer.slice(bodyIndex + 4);
|
|
18
|
+
headerString = headerString.replace(/\r\n/g, '\n');
|
|
19
|
+
const lines = headerString.split('\n');
|
|
20
|
+
bodyIndex += 2;
|
|
21
|
+
// ...
|
|
22
|
+
let line = lines.shift();
|
|
23
|
+
if (line) {
|
|
24
|
+
parseStartLine(line, messageObject);
|
|
25
|
+
}
|
|
26
|
+
// ...
|
|
27
|
+
line = lines.shift();
|
|
28
|
+
while (line) {
|
|
29
|
+
const headerName = line.substr(0, line.indexOf(':'));
|
|
30
|
+
const headerValue = line.substr(line.indexOf(':') + 1);
|
|
31
|
+
messageObject.headers[headerName] = headerValue.trim();
|
|
32
|
+
line = lines.shift();
|
|
33
|
+
}
|
|
34
|
+
// ...
|
|
35
|
+
if (messageObject.headers['Content-Length'] && messageObject.headers['Content-Length'] !== '0') {
|
|
36
|
+
messageObject.body = body;
|
|
37
|
+
}
|
|
38
|
+
return messageObject;
|
|
39
|
+
};
|
|
40
|
+
instance.write = (messageObject) => {
|
|
41
|
+
let messageString = writeStartLine(messageObject);
|
|
42
|
+
messageString += '\r\n';
|
|
43
|
+
if (messageObject.body) {
|
|
44
|
+
messageObject.headers['Content-Length'] = String(Buffer.byteLength(messageObject.body));
|
|
45
|
+
}
|
|
46
|
+
for (const header in messageObject.headers) {
|
|
47
|
+
messageString += `${header}: ${messageObject.headers[header]}\r\n`;
|
|
48
|
+
}
|
|
49
|
+
messageString += '\r\n';
|
|
50
|
+
const buffer = Buffer.from(messageString);
|
|
51
|
+
if (!messageObject.body) {
|
|
52
|
+
return buffer;
|
|
53
|
+
}
|
|
54
|
+
return Buffer.concat([buffer, messageObject.body], buffer.length + messageObject.body.length);
|
|
55
|
+
};
|
|
56
|
+
return instance;
|
|
57
|
+
};
|
|
58
|
+
const HttpRequest = () => HttpMessage(() => { }, // currently not parsing requests.
|
|
59
|
+
(messageObject) => `${messageObject.method} ${messageObject.path} HTTP/1.1`);
|
|
60
|
+
const HttpResponse = () => HttpMessage((line, messageObject) => {
|
|
61
|
+
messageObject.statusCode = parseInt(line.split(' ')[1], 10);
|
|
62
|
+
}, () => '');
|
|
63
|
+
// ...
|
|
64
|
+
class HttpClient {
|
|
65
|
+
resolveQueue = [];
|
|
66
|
+
pendingResponse = null;
|
|
67
|
+
socket;
|
|
68
|
+
host;
|
|
69
|
+
// ....
|
|
70
|
+
parseResponse(data) {
|
|
71
|
+
const res = HttpResponse().parse(data);
|
|
72
|
+
if (res.headers['Content-Length'] && Number(res.headers['Content-Length']) > 0) {
|
|
73
|
+
const remaining = Number(res.headers['Content-Length']) - (res.body?.byteLength ?? 0);
|
|
74
|
+
if (remaining > 0) {
|
|
75
|
+
// not all data for this response's corresponding request was read. Create a pending response object
|
|
76
|
+
// to use for further reads.
|
|
77
|
+
this.pendingResponse = {
|
|
78
|
+
res,
|
|
79
|
+
remaining
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (!this.pendingResponse) {
|
|
84
|
+
const rr = this.resolveQueue.shift();
|
|
85
|
+
if (!rr)
|
|
86
|
+
return;
|
|
87
|
+
res.statusCode === 200
|
|
88
|
+
? rr.resolve(res)
|
|
89
|
+
: rr.resolve(null);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
// ...
|
|
93
|
+
connect(host, port = 80) {
|
|
94
|
+
this.host = host;
|
|
95
|
+
return new Promise(resolve => {
|
|
96
|
+
this.socket = node_net_1.default.connect({
|
|
97
|
+
host,
|
|
98
|
+
port
|
|
99
|
+
}, resolve);
|
|
100
|
+
this.socket.on('data', data => {
|
|
101
|
+
if (!this.pendingResponse) {
|
|
102
|
+
// there is no response pending, parse the data.
|
|
103
|
+
this.parseResponse(data);
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
// incoming data for the pending response.
|
|
107
|
+
const existing = this.pendingResponse.res.body ?? Buffer.alloc(0);
|
|
108
|
+
this.pendingResponse.res.body = Buffer.concat([existing, data], data.byteLength + existing.byteLength);
|
|
109
|
+
this.pendingResponse.remaining -= data.byteLength;
|
|
110
|
+
if (this.pendingResponse.remaining === 0) {
|
|
111
|
+
// all remaining data for the pending response has been read; resolve the promise for the
|
|
112
|
+
// corresponding request.
|
|
113
|
+
const rr = this.resolveQueue.shift();
|
|
114
|
+
if (!rr) {
|
|
115
|
+
this.pendingResponse = null;
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
this.pendingResponse.res.statusCode === 200
|
|
119
|
+
? rr.resolve(this.pendingResponse.res)
|
|
120
|
+
: rr.reject(new Error(`HTTP status: ${this.pendingResponse.res.statusCode}`));
|
|
121
|
+
this.pendingResponse = null;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
request(method, path, headers, body) {
|
|
128
|
+
headers = headers || {};
|
|
129
|
+
// headers['Host'] = `${this.host}:${this.socket.remotePort}`;
|
|
130
|
+
const data = HttpRequest().write({
|
|
131
|
+
method,
|
|
132
|
+
path,
|
|
133
|
+
headers,
|
|
134
|
+
body
|
|
135
|
+
});
|
|
136
|
+
// ...
|
|
137
|
+
return new Promise((resolve, reject) => {
|
|
138
|
+
this.resolveQueue.push({ resolve, reject });
|
|
139
|
+
this.socket?.write(data);
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
close() {
|
|
143
|
+
this.socket?.end();
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
// ...
|
|
147
|
+
const createHttpClient = () => new HttpClient();
|
|
148
|
+
exports.default = createHttpClient;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export type NtpTimestampInput = bigint | number | {
|
|
2
|
+
sec: number;
|
|
3
|
+
frac: number;
|
|
4
|
+
};
|
|
5
|
+
declare class NTP {
|
|
6
|
+
/** Convert monotonic clock to NTP epoch (1900-01-01) seconds + fractional. */
|
|
7
|
+
timestamp(): Buffer;
|
|
8
|
+
/** Return the current NTP fractional component (for compatibility). */
|
|
9
|
+
getTime(): number;
|
|
10
|
+
}
|
|
11
|
+
declare const _default: NTP;
|
|
12
|
+
export default _default;
|
|
13
|
+
/** Pack various NTP timestamp representations into a uint64 bigint (sec<<32|frac). */
|
|
14
|
+
export declare function toNtpTimestamp(input: NtpTimestampInput): bigint;
|
|
15
|
+
/** Parse an NTP timestamp (uint64 or sec/frac) into components. */
|
|
16
|
+
export declare function parseNtpTimestamp(input: NtpTimestampInput): {
|
|
17
|
+
sec: number;
|
|
18
|
+
frac: number;
|
|
19
|
+
};
|
|
20
|
+
/** Build an NTP timestamp from a Unix epoch (ms). */
|
|
21
|
+
export declare function ntpFromUnixMs(unixMs: number): bigint;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.toNtpTimestamp = toNtpTimestamp;
|
|
7
|
+
exports.parseNtpTimestamp = parseNtpTimestamp;
|
|
8
|
+
exports.ntpFromUnixMs = ntpFromUnixMs;
|
|
9
|
+
const config_1 = __importDefault(require("./config"));
|
|
10
|
+
const NS_PER_SEC = 1000000000n;
|
|
11
|
+
const FRAC_PER_SEC = 0x100000000n; // 2^32
|
|
12
|
+
class NTP {
|
|
13
|
+
/** Convert monotonic clock to NTP epoch (1900-01-01) seconds + fractional. */
|
|
14
|
+
timestamp() {
|
|
15
|
+
const nowNs = process.hrtime.bigint();
|
|
16
|
+
const sec = Number(nowNs / NS_PER_SEC) + config_1.default.ntp_epoch;
|
|
17
|
+
const frac = Number(((nowNs % NS_PER_SEC) * FRAC_PER_SEC) / NS_PER_SEC);
|
|
18
|
+
const ts = Buffer.alloc(8);
|
|
19
|
+
ts.writeUInt32BE(sec >>> 0, 0);
|
|
20
|
+
ts.writeUInt32BE(frac >>> 0, 4);
|
|
21
|
+
return ts;
|
|
22
|
+
}
|
|
23
|
+
/** Return the current NTP fractional component (for compatibility). */
|
|
24
|
+
getTime() {
|
|
25
|
+
const nowNs = process.hrtime.bigint();
|
|
26
|
+
return Number(((nowNs % NS_PER_SEC) * FRAC_PER_SEC) / NS_PER_SEC);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
exports.default = new NTP();
|
|
30
|
+
/** Pack various NTP timestamp representations into a uint64 bigint (sec<<32|frac). */
|
|
31
|
+
function toNtpTimestamp(input) {
|
|
32
|
+
if (typeof input === 'bigint')
|
|
33
|
+
return input;
|
|
34
|
+
if (typeof input === 'number')
|
|
35
|
+
return BigInt(input);
|
|
36
|
+
const sec = BigInt(input.sec >>> 0);
|
|
37
|
+
const frac = BigInt(input.frac >>> 0);
|
|
38
|
+
return (sec << 32n) | frac;
|
|
39
|
+
}
|
|
40
|
+
/** Parse an NTP timestamp (uint64 or sec/frac) into components. */
|
|
41
|
+
function parseNtpTimestamp(input) {
|
|
42
|
+
if (typeof input === 'bigint' || typeof input === 'number') {
|
|
43
|
+
const value = typeof input === 'bigint' ? input : BigInt(input);
|
|
44
|
+
const sec = Number(value >> 32n);
|
|
45
|
+
const frac = Number(value & 0xffffffffn);
|
|
46
|
+
return { sec, frac };
|
|
47
|
+
}
|
|
48
|
+
return { sec: input.sec, frac: input.frac };
|
|
49
|
+
}
|
|
50
|
+
/** Build an NTP timestamp from a Unix epoch (ms). */
|
|
51
|
+
function ntpFromUnixMs(unixMs) {
|
|
52
|
+
const sec = Math.floor(unixMs / 1000) + config_1.default.ntp_epoch;
|
|
53
|
+
const ms = unixMs % 1000;
|
|
54
|
+
const frac = Math.floor((ms / 1000) * Number(FRAC_PER_SEC));
|
|
55
|
+
return (BigInt(sec >>> 0) << 32n) | BigInt(frac >>> 0);
|
|
56
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export declare const randomHex: (n: number) => string;
|
|
2
|
+
export declare const randomBase64: (n: number) => string;
|
|
3
|
+
export declare const randomInt: (n: number) => number;
|
|
4
|
+
export declare const low16: (i: number) => number;
|
|
5
|
+
export declare const low32: (i: number) => number;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.low32 = exports.low16 = exports.randomInt = exports.randomBase64 = exports.randomHex = void 0;
|
|
7
|
+
const node_crypto_1 = __importDefault(require("node:crypto"));
|
|
8
|
+
const randomHex = (n) => node_crypto_1.default.randomBytes(n).toString('hex');
|
|
9
|
+
exports.randomHex = randomHex;
|
|
10
|
+
const randomBase64 = (n) => node_crypto_1.default.randomBytes(n).toString('base64').replace('=', '');
|
|
11
|
+
exports.randomBase64 = randomBase64;
|
|
12
|
+
const randomInt = (n) => Math.floor(Math.random() * Math.pow(10, n));
|
|
13
|
+
exports.randomInt = randomInt;
|
|
14
|
+
const low16 = (i) => Math.abs(i) % 65536;
|
|
15
|
+
exports.low16 = low16;
|
|
16
|
+
const low32 = (i) => Math.abs(i) % 4294967296;
|
|
17
|
+
exports.low32 = low32;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reusable packet structure holding PCM/ALAC data plus sequence.
|
|
3
|
+
* Reference-counted to reduce allocations in the streaming path.
|
|
4
|
+
*/
|
|
5
|
+
export declare class Packet {
|
|
6
|
+
private readonly pool;
|
|
7
|
+
private ref;
|
|
8
|
+
seq: number | null;
|
|
9
|
+
readonly pcm: Buffer;
|
|
10
|
+
constructor(pool: PacketPool, packetSize: number);
|
|
11
|
+
/** Increment ref count when sharing the packet. */
|
|
12
|
+
retain(): void;
|
|
13
|
+
/** Decrement ref count and return to pool when free. */
|
|
14
|
+
release(): void;
|
|
15
|
+
}
|
|
16
|
+
/** Simple pool of Packet instances to avoid GC pressure during streaming. */
|
|
17
|
+
export default class PacketPool {
|
|
18
|
+
private readonly packetSize;
|
|
19
|
+
private readonly pool;
|
|
20
|
+
constructor(packetSize: number);
|
|
21
|
+
/** Borrow a packet from the pool or allocate a new one. */
|
|
22
|
+
getPacket(): Packet;
|
|
23
|
+
/** Return a packet to the pool. */
|
|
24
|
+
release(packet: Packet): void;
|
|
25
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.Packet = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* Reusable packet structure holding PCM/ALAC data plus sequence.
|
|
6
|
+
* Reference-counted to reduce allocations in the streaming path.
|
|
7
|
+
*/
|
|
8
|
+
class Packet {
|
|
9
|
+
pool;
|
|
10
|
+
ref = 1;
|
|
11
|
+
seq = null;
|
|
12
|
+
pcm;
|
|
13
|
+
constructor(pool, packetSize) {
|
|
14
|
+
this.pool = pool;
|
|
15
|
+
this.pcm = Buffer.alloc(packetSize);
|
|
16
|
+
}
|
|
17
|
+
/** Increment ref count when sharing the packet. */
|
|
18
|
+
retain() {
|
|
19
|
+
this.ref += 1;
|
|
20
|
+
}
|
|
21
|
+
/** Decrement ref count and return to pool when free. */
|
|
22
|
+
release() {
|
|
23
|
+
this.ref -= 1;
|
|
24
|
+
if (this.ref === 0) {
|
|
25
|
+
this.seq = null;
|
|
26
|
+
this.pool.release(this);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
exports.Packet = Packet;
|
|
31
|
+
/** Simple pool of Packet instances to avoid GC pressure during streaming. */
|
|
32
|
+
class PacketPool {
|
|
33
|
+
packetSize;
|
|
34
|
+
pool = [];
|
|
35
|
+
constructor(packetSize) {
|
|
36
|
+
this.packetSize = packetSize;
|
|
37
|
+
}
|
|
38
|
+
/** Borrow a packet from the pool or allocate a new one. */
|
|
39
|
+
getPacket() {
|
|
40
|
+
const packet = this.pool.shift();
|
|
41
|
+
if (!packet) {
|
|
42
|
+
return new Packet(this, this.packetSize);
|
|
43
|
+
}
|
|
44
|
+
packet.retain();
|
|
45
|
+
return packet;
|
|
46
|
+
}
|
|
47
|
+
/** Return a packet to the pool. */
|
|
48
|
+
release(packet) {
|
|
49
|
+
this.pool.push(packet);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
exports.default = PacketPool;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.buf2hex = exports.hexString2ArrayBuffer = void 0;
|
|
4
|
+
const hexString2ArrayBuffer = (hexString) => new Uint8Array(hexString.match(/[\da-f]{2}/gi)?.map((h) => parseInt(h, 16)) ?? []);
|
|
5
|
+
exports.hexString2ArrayBuffer = hexString2ArrayBuffer;
|
|
6
|
+
const buf2hex = (buffer) => Array.prototype.map
|
|
7
|
+
.call(new Uint8Array(buffer), (x) => `00${x.toString(16)}`.slice(-2))
|
|
8
|
+
.join('');
|
|
9
|
+
exports.buf2hex = buf2hex;
|
package/package.json
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@lox-audioserver/node-airplay-sender",
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "AirPlay sender (RAOP/AirPlay 1; AirPlay 2 control/auth, best-effort audio)",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"require": "./dist/index.js",
|
|
11
|
+
"import": "./dist/esm/index.js",
|
|
12
|
+
"default": "./dist/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist/**/*",
|
|
17
|
+
"README.md",
|
|
18
|
+
"LICENSE"
|
|
19
|
+
],
|
|
20
|
+
"scripts": {
|
|
21
|
+
"build": "npm run build:cjs && npm run build:esm",
|
|
22
|
+
"build:cjs": "tsc -p tsconfig.json",
|
|
23
|
+
"build:esm": "tsc -p tsconfig.esm.json && node -e \"const fs=require('fs');fs.mkdirSync('dist/esm',{recursive:true});fs.writeFileSync('dist/esm/package.json','{\\\"type\\\":\\\"module\\\"}\\n');\"",
|
|
24
|
+
"clean": "rimraf dist",
|
|
25
|
+
"prepare": "npm run build"
|
|
26
|
+
},
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "git+https://github.com/lox-audioserver/node-airplay-sender.git"
|
|
30
|
+
},
|
|
31
|
+
"bugs": {
|
|
32
|
+
"url": "https://github.com/lox-audioserver/node-airplay-sender/issues"
|
|
33
|
+
},
|
|
34
|
+
"homepage": "https://github.com/lox-audioserver/node-airplay-sender#readme",
|
|
35
|
+
"keywords": [
|
|
36
|
+
"airplay",
|
|
37
|
+
"raop",
|
|
38
|
+
"audio",
|
|
39
|
+
"streaming"
|
|
40
|
+
],
|
|
41
|
+
"engines": {
|
|
42
|
+
"node": ">=18"
|
|
43
|
+
},
|
|
44
|
+
"publishConfig": {
|
|
45
|
+
"access": "public"
|
|
46
|
+
},
|
|
47
|
+
"dependencies": {
|
|
48
|
+
"@noble/ed25519": "^1.7.3",
|
|
49
|
+
"async": "^3.2.5",
|
|
50
|
+
"big-integer": "^1.6.52",
|
|
51
|
+
"bplist-creator": "^0.1.1",
|
|
52
|
+
"bplist-parser": "^0.3.2",
|
|
53
|
+
"crypto-js": "^4.2.0",
|
|
54
|
+
"curve25519-js": "^0.0.4",
|
|
55
|
+
"elliptic": "^6.5.5",
|
|
56
|
+
"fast-srp-hap": "^2.0.4",
|
|
57
|
+
"js-crypto-aes": "^1.0.6",
|
|
58
|
+
"js-sha1": "^0.7.0",
|
|
59
|
+
"lodash": "^4.17.21",
|
|
60
|
+
"parse-raw-http": "0.0.1",
|
|
61
|
+
"python-struct": "^1.1.3",
|
|
62
|
+
"simple-plist": "^1.4.0",
|
|
63
|
+
"varint": "^6.0.0"
|
|
64
|
+
},
|
|
65
|
+
"devDependencies": {
|
|
66
|
+
"@types/node": "^22.19.3",
|
|
67
|
+
"rimraf": "^6.0.1",
|
|
68
|
+
"typescript": "^5.9.3"
|
|
69
|
+
},
|
|
70
|
+
"license": "AGPL-3.0"
|
|
71
|
+
}
|