@basmilius/apple-raop 0.9.19 → 0.10.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/dist/index.d.mts +502 -26
- package/dist/index.mjs +622 -19
- package/package.json +5 -5
package/dist/index.mjs
CHANGED
|
@@ -6,32 +6,74 @@ import { Context, Discovery, generateActiveRemoteId, generateDacpId, generateSes
|
|
|
6
6
|
import { RtspClient } from "@basmilius/apple-rtsp";
|
|
7
7
|
|
|
8
8
|
//#region src/types.ts
|
|
9
|
+
/**
|
|
10
|
+
* Bitmask enum for encryption types supported by the RAOP receiver.
|
|
11
|
+
* Values are parsed from the `et` mDNS TXT record field.
|
|
12
|
+
*/
|
|
9
13
|
let EncryptionType = /* @__PURE__ */ function(EncryptionType) {
|
|
14
|
+
/** No encryption information available. */
|
|
10
15
|
EncryptionType[EncryptionType["Unknown"] = 0] = "Unknown";
|
|
16
|
+
/** Receiver supports unencrypted streams. */
|
|
11
17
|
EncryptionType[EncryptionType["Unencrypted"] = 1] = "Unencrypted";
|
|
18
|
+
/** Receiver supports MFi-SAP encryption (AirPort Express). */
|
|
12
19
|
EncryptionType[EncryptionType["MFiSAP"] = 2] = "MFiSAP";
|
|
13
20
|
return EncryptionType;
|
|
14
21
|
}({});
|
|
22
|
+
/**
|
|
23
|
+
* Bitmask enum for metadata types supported by the RAOP receiver.
|
|
24
|
+
* Values are parsed from the `md` mDNS TXT record field.
|
|
25
|
+
*/
|
|
15
26
|
let MetadataType = /* @__PURE__ */ function(MetadataType) {
|
|
27
|
+
/** Receiver does not support metadata. */
|
|
16
28
|
MetadataType[MetadataType["NotSupported"] = 0] = "NotSupported";
|
|
29
|
+
/** Receiver supports text metadata (title, artist, album). */
|
|
17
30
|
MetadataType[MetadataType["Text"] = 1] = "Text";
|
|
31
|
+
/** Receiver supports album artwork. */
|
|
18
32
|
MetadataType[MetadataType["Artwork"] = 2] = "Artwork";
|
|
33
|
+
/** Receiver supports progress/duration information. */
|
|
19
34
|
MetadataType[MetadataType["Progress"] = 4] = "Progress";
|
|
20
35
|
return MetadataType;
|
|
21
36
|
}({});
|
|
22
37
|
|
|
23
38
|
//#endregion
|
|
24
39
|
//#region src/packets.ts
|
|
40
|
+
/**
|
|
41
|
+
* Fixed-size FIFO buffer for recently sent RTP audio packets.
|
|
42
|
+
* Used to fulfill retransmission requests from the receiver when
|
|
43
|
+
* packets are lost in transit.
|
|
44
|
+
*/
|
|
25
45
|
var PacketFifo = class {
|
|
46
|
+
/** Maximum number of packets to retain. */
|
|
26
47
|
#maxSize;
|
|
48
|
+
/** Map of sequence number to packet data. */
|
|
27
49
|
#packets = /* @__PURE__ */ new Map();
|
|
50
|
+
/** Insertion-ordered list of sequence numbers for eviction. */
|
|
28
51
|
#order = [];
|
|
52
|
+
/**
|
|
53
|
+
* Creates a new packet FIFO with the given capacity.
|
|
54
|
+
*
|
|
55
|
+
* @param maxSize - Maximum number of packets to store before evicting the oldest.
|
|
56
|
+
*/
|
|
29
57
|
constructor(maxSize) {
|
|
30
58
|
this.#maxSize = maxSize;
|
|
31
59
|
}
|
|
60
|
+
/**
|
|
61
|
+
* Retrieves a packet by its RTP sequence number.
|
|
62
|
+
*
|
|
63
|
+
* @param seqno - RTP sequence number to look up.
|
|
64
|
+
* @returns The packet buffer, or undefined if not in the backlog.
|
|
65
|
+
*/
|
|
32
66
|
get(seqno) {
|
|
33
67
|
return this.#packets.get(seqno);
|
|
34
68
|
}
|
|
69
|
+
/**
|
|
70
|
+
* Stores a packet in the backlog. If the sequence number already
|
|
71
|
+
* exists, the call is ignored. When the backlog exceeds its maximum
|
|
72
|
+
* size, the oldest packets are evicted.
|
|
73
|
+
*
|
|
74
|
+
* @param seqno - RTP sequence number of the packet.
|
|
75
|
+
* @param packet - Full RTP packet data including header.
|
|
76
|
+
*/
|
|
35
77
|
set(seqno, packet) {
|
|
36
78
|
if (this.#packets.has(seqno)) return;
|
|
37
79
|
this.#packets.set(seqno, packet);
|
|
@@ -41,14 +83,27 @@ var PacketFifo = class {
|
|
|
41
83
|
if (oldest !== void 0) this.#packets.delete(oldest);
|
|
42
84
|
}
|
|
43
85
|
}
|
|
86
|
+
/**
|
|
87
|
+
* Checks whether a packet with the given sequence number is in the backlog.
|
|
88
|
+
*
|
|
89
|
+
* @param seqno - RTP sequence number to check.
|
|
90
|
+
* @returns True if the packet exists in the backlog.
|
|
91
|
+
*/
|
|
44
92
|
has(seqno) {
|
|
45
93
|
return this.#packets.has(seqno);
|
|
46
94
|
}
|
|
95
|
+
/**
|
|
96
|
+
* Removes all packets from the backlog.
|
|
97
|
+
*/
|
|
47
98
|
clear() {
|
|
48
99
|
this.#packets.clear();
|
|
49
100
|
this.#order.length = 0;
|
|
50
101
|
}
|
|
51
102
|
};
|
|
103
|
+
/**
|
|
104
|
+
* Encoder for RTP audio packet headers (12 bytes).
|
|
105
|
+
* Produces the standard RTP fixed header used for RAOP audio data packets.
|
|
106
|
+
*/
|
|
52
107
|
const AudioPacketHeader = { encode(header, payloadType, seqno, timestamp, ssrc) {
|
|
53
108
|
const packet = Buffer.allocUnsafe(12);
|
|
54
109
|
packet.writeUInt8(header, 0);
|
|
@@ -58,6 +113,11 @@ const AudioPacketHeader = { encode(header, payloadType, seqno, timestamp, ssrc)
|
|
|
58
113
|
packet.writeUInt32BE(ssrc, 8);
|
|
59
114
|
return packet;
|
|
60
115
|
} };
|
|
116
|
+
/**
|
|
117
|
+
* Encoder for RAOP timing synchronization packets (20 bytes).
|
|
118
|
+
* Sent periodically over the control channel to synchronize
|
|
119
|
+
* the receiver's playback clock with the sender's RTP timestamps.
|
|
120
|
+
*/
|
|
61
121
|
const SyncPacket = { encode(header, payloadType, seqno, rtpTimestamp, ntpSec, ntpFrac, rtpTimestampNow) {
|
|
62
122
|
const packet = Buffer.allocUnsafe(20);
|
|
63
123
|
packet.writeUInt8(header, 0);
|
|
@@ -69,6 +129,13 @@ const SyncPacket = { encode(header, payloadType, seqno, rtpTimestamp, ntpSec, nt
|
|
|
69
129
|
packet.writeUInt32BE(rtpTimestampNow, 16);
|
|
70
130
|
return packet;
|
|
71
131
|
} };
|
|
132
|
+
/**
|
|
133
|
+
* Decodes a retransmit request packet received on the control channel.
|
|
134
|
+
* The request contains the starting sequence number and count of lost packets.
|
|
135
|
+
*
|
|
136
|
+
* @param data - Raw UDP packet data from the receiver.
|
|
137
|
+
* @returns Parsed retransmit request with sequence number and packet count.
|
|
138
|
+
*/
|
|
72
139
|
function decodeRetransmitRequest(data) {
|
|
73
140
|
return {
|
|
74
141
|
lostSeqno: data.readUInt16BE(4),
|
|
@@ -78,11 +145,25 @@ function decodeRetransmitRequest(data) {
|
|
|
78
145
|
|
|
79
146
|
//#endregion
|
|
80
147
|
//#region src/utils.ts
|
|
148
|
+
/**
|
|
149
|
+
* Converts a linear volume percentage (0-100) to a dBFS value
|
|
150
|
+
* suitable for the RAOP `volume` SET_PARAMETER command.
|
|
151
|
+
*
|
|
152
|
+
* @param volume - Volume as a percentage (0 = silent, 100 = full).
|
|
153
|
+
* @returns Volume in dBFS (-144 for mute, 0 for full volume).
|
|
154
|
+
*/
|
|
81
155
|
function pctToDbfs(volume) {
|
|
82
156
|
if (volume <= 0) return -144;
|
|
83
157
|
if (volume >= 100) return 0;
|
|
84
158
|
return 20 * Math.log10(volume / 100);
|
|
85
159
|
}
|
|
160
|
+
/**
|
|
161
|
+
* Parses the `et` (encryption types) field from mDNS TXT record
|
|
162
|
+
* properties into an EncryptionType bitmask.
|
|
163
|
+
*
|
|
164
|
+
* @param properties - mDNS TXT record key-value pairs.
|
|
165
|
+
* @returns Bitmask of supported encryption types.
|
|
166
|
+
*/
|
|
86
167
|
function getEncryptionTypes(properties) {
|
|
87
168
|
const et = properties.get("et");
|
|
88
169
|
if (!et) return EncryptionType.Unknown;
|
|
@@ -94,6 +175,13 @@ function getEncryptionTypes(properties) {
|
|
|
94
175
|
}
|
|
95
176
|
return types;
|
|
96
177
|
}
|
|
178
|
+
/**
|
|
179
|
+
* Parses the `md` (metadata types) field from mDNS TXT record
|
|
180
|
+
* properties into a MetadataType bitmask.
|
|
181
|
+
*
|
|
182
|
+
* @param properties - mDNS TXT record key-value pairs.
|
|
183
|
+
* @returns Bitmask of supported metadata types.
|
|
184
|
+
*/
|
|
97
185
|
function getMetadataTypes(properties) {
|
|
98
186
|
const md = properties.get("md");
|
|
99
187
|
if (!md) return MetadataType.NotSupported;
|
|
@@ -106,6 +194,14 @@ function getMetadataTypes(properties) {
|
|
|
106
194
|
}
|
|
107
195
|
return types;
|
|
108
196
|
}
|
|
197
|
+
/**
|
|
198
|
+
* Extracts audio format properties from mDNS TXT record fields.
|
|
199
|
+
* Falls back to CD-quality defaults (44100 Hz, 2 channels, 16-bit)
|
|
200
|
+
* when properties are missing.
|
|
201
|
+
*
|
|
202
|
+
* @param properties - mDNS TXT record key-value pairs.
|
|
203
|
+
* @returns A tuple of [sampleRate, channels, bytesPerChannel].
|
|
204
|
+
*/
|
|
109
205
|
function getAudioProperties(properties) {
|
|
110
206
|
return [
|
|
111
207
|
parseInt(properties.get("sr") ?? "44100", 10),
|
|
@@ -116,30 +212,81 @@ function getAudioProperties(properties) {
|
|
|
116
212
|
|
|
117
213
|
//#endregion
|
|
118
214
|
//#region src/controlClient.ts
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
215
|
+
/**
|
|
216
|
+
* Converts an RTP timestamp to a wall-clock NTP timestamp using anchor points.
|
|
217
|
+
*
|
|
218
|
+
* Uses a fixed anchor pair (RTP timestamp + NTP time) established when the
|
|
219
|
+
* stream starts. The elapsed samples from the anchor are computed with 32-bit
|
|
220
|
+
* unsigned wrap handling, then converted to an NTP offset added to the anchor NTP.
|
|
221
|
+
*
|
|
222
|
+
* @param rtpTimestamp - Current RTP timestamp in audio frames.
|
|
223
|
+
* @param sampleRate - Audio sample rate in Hz.
|
|
224
|
+
* @param anchorRtp - RTP timestamp at the anchor point.
|
|
225
|
+
* @param anchorNtp - NTP timestamp at the anchor point.
|
|
226
|
+
* @returns 64-bit NTP wall-clock timestamp.
|
|
227
|
+
*/
|
|
228
|
+
function ntpFromTs(rtpTimestamp, sampleRate, anchorRtp, anchorNtp) {
|
|
229
|
+
let elapsedSamples;
|
|
230
|
+
if (rtpTimestamp >= anchorRtp) elapsedSamples = rtpTimestamp - anchorRtp;
|
|
231
|
+
else elapsedSamples = 4294967296 - anchorRtp + rtpTimestamp;
|
|
232
|
+
const elapsedSeconds = Math.floor(elapsedSamples / sampleRate);
|
|
233
|
+
const elapsedFraction = elapsedSamples % sampleRate * 4294967295 / sampleRate;
|
|
234
|
+
return anchorNtp + (BigInt(elapsedSeconds) << 32n | BigInt(Math.floor(elapsedFraction)));
|
|
123
235
|
}
|
|
236
|
+
/**
|
|
237
|
+
* UDP control channel client for RAOP streaming. Responsible for two tasks:
|
|
238
|
+
* 1. Sending periodic timing sync packets to the receiver so it can
|
|
239
|
+
* synchronize its playback clock with our RTP timestamps.
|
|
240
|
+
* 2. Handling retransmit requests from the receiver by resending lost
|
|
241
|
+
* packets from the packet backlog.
|
|
242
|
+
*/
|
|
124
243
|
var ControlClient = class extends EventEmitter {
|
|
244
|
+
/** Application context providing logger and device identity. */
|
|
245
|
+
#appContext;
|
|
246
|
+
/** UDP socket for the control channel. */
|
|
125
247
|
#transport;
|
|
248
|
+
/** Shared streaming state containing timestamps, ports, and audio format. */
|
|
126
249
|
#context;
|
|
250
|
+
/** FIFO backlog of recently sent packets for retransmission. */
|
|
127
251
|
#packetBacklog;
|
|
252
|
+
/** Interval timer handle for periodic sync packet sending. */
|
|
128
253
|
#syncTask;
|
|
254
|
+
/** Local UDP port assigned after binding. */
|
|
129
255
|
#localPort;
|
|
130
|
-
|
|
256
|
+
/** RTP timestamp at the anchor point for NTP conversion. */
|
|
257
|
+
#anchorRtp = 0;
|
|
258
|
+
/** NTP wall-clock timestamp at the anchor point. */
|
|
259
|
+
#anchorNtp = 0n;
|
|
260
|
+
/**
|
|
261
|
+
* Creates a new control client.
|
|
262
|
+
*
|
|
263
|
+
* @param appContext - Application context for logging.
|
|
264
|
+
* @param context - Shared stream context with RTP state.
|
|
265
|
+
* @param packetBacklog - Packet FIFO for retransmit lookups.
|
|
266
|
+
*/
|
|
267
|
+
constructor(appContext, context, packetBacklog) {
|
|
131
268
|
super();
|
|
269
|
+
this.#appContext = appContext;
|
|
132
270
|
this.#context = context;
|
|
133
271
|
this.#packetBacklog = packetBacklog;
|
|
134
272
|
}
|
|
273
|
+
/** Local UDP port the control channel is bound to, or 0 if not yet bound. */
|
|
135
274
|
get port() {
|
|
136
275
|
return this.#localPort ?? 0;
|
|
137
276
|
}
|
|
277
|
+
/**
|
|
278
|
+
* Binds the control channel UDP socket to a local address and port.
|
|
279
|
+
* Resolves once the socket is listening and the assigned port is known.
|
|
280
|
+
*
|
|
281
|
+
* @param localIp - Local IP address to bind to.
|
|
282
|
+
* @param port - Desired port number (0 for auto-assign).
|
|
283
|
+
* @throws When the UDP socket encounters a bind error.
|
|
284
|
+
*/
|
|
138
285
|
async bind(localIp, port) {
|
|
139
286
|
return new Promise((resolve, reject) => {
|
|
140
287
|
this.#transport = createSocket("udp4");
|
|
141
288
|
this.#transport.on("error", (err) => {
|
|
142
|
-
|
|
289
|
+
this.#appContext.logger.error("[raop-control]", "Control connection error:", err);
|
|
143
290
|
reject(err);
|
|
144
291
|
});
|
|
145
292
|
this.#transport.on("message", (data, rinfo) => {
|
|
@@ -152,6 +299,9 @@ var ControlClient = class extends EventEmitter {
|
|
|
152
299
|
this.#transport.bind(port, localIp);
|
|
153
300
|
});
|
|
154
301
|
}
|
|
302
|
+
/**
|
|
303
|
+
* Stops the sync task and closes the UDP socket, releasing all resources.
|
|
304
|
+
*/
|
|
155
305
|
close() {
|
|
156
306
|
this.stop();
|
|
157
307
|
if (this.#transport) {
|
|
@@ -159,21 +309,41 @@ var ControlClient = class extends EventEmitter {
|
|
|
159
309
|
this.#transport = void 0;
|
|
160
310
|
}
|
|
161
311
|
}
|
|
312
|
+
/**
|
|
313
|
+
* Starts sending periodic sync packets to the receiver. Sync packets
|
|
314
|
+
* are sent immediately and then every second to keep the receiver's
|
|
315
|
+
* playback clock aligned.
|
|
316
|
+
*
|
|
317
|
+
* @param remoteAddr - IP address of the RAOP receiver.
|
|
318
|
+
* @throws When the sync task is already running.
|
|
319
|
+
*/
|
|
162
320
|
start(remoteAddr) {
|
|
163
321
|
if (this.#syncTask) throw new Error("Already running");
|
|
322
|
+
this.#anchorRtp = this.#context.headTs;
|
|
323
|
+
this.#anchorNtp = NTP.now();
|
|
164
324
|
this.#startSyncTask(remoteAddr, this.#context.controlPort);
|
|
165
325
|
}
|
|
326
|
+
/**
|
|
327
|
+
* Stops the periodic sync task without closing the UDP socket.
|
|
328
|
+
*/
|
|
166
329
|
stop() {
|
|
167
330
|
if (this.#syncTask) {
|
|
168
331
|
clearInterval(this.#syncTask);
|
|
169
332
|
this.#syncTask = void 0;
|
|
170
333
|
}
|
|
171
334
|
}
|
|
335
|
+
/**
|
|
336
|
+
* Starts the periodic sync packet sending loop. The first packet uses
|
|
337
|
+
* header byte 0x90 (marker bit set), subsequent packets use 0x80.
|
|
338
|
+
*
|
|
339
|
+
* @param addr - Remote receiver IP address.
|
|
340
|
+
* @param port - Remote control port.
|
|
341
|
+
*/
|
|
172
342
|
#startSyncTask(addr, port) {
|
|
173
343
|
let firstPacket = true;
|
|
174
344
|
const sendSync = () => {
|
|
175
345
|
if (!this.#transport) return;
|
|
176
|
-
const currentTime = ntpFromTs(this.#context.headTs, this.#context.sampleRate);
|
|
346
|
+
const currentTime = ntpFromTs(this.#context.headTs, this.#context.sampleRate, this.#anchorRtp, this.#anchorNtp);
|
|
177
347
|
const [currentSec, currentFrac] = NTP.parts(currentTime);
|
|
178
348
|
const packet = SyncPacket.encode(firstPacket ? 144 : 128, 212, 7, this.#context.headTs - this.#context.latency, currentSec, currentFrac, this.#context.headTs);
|
|
179
349
|
firstPacket = false;
|
|
@@ -182,10 +352,26 @@ var ControlClient = class extends EventEmitter {
|
|
|
182
352
|
sendSync();
|
|
183
353
|
this.#syncTask = setInterval(sendSync, 1e3);
|
|
184
354
|
}
|
|
355
|
+
/**
|
|
356
|
+
* Handles incoming UDP messages on the control channel. Dispatches
|
|
357
|
+
* retransmit requests (type 0x55) and logs unhandled message types.
|
|
358
|
+
*
|
|
359
|
+
* @param data - Raw UDP packet data.
|
|
360
|
+
* @param rinfo - Remote address info of the sender.
|
|
361
|
+
*/
|
|
185
362
|
#onMessage(data, rinfo) {
|
|
186
363
|
if ((data[1] & 127) === 85) this.#retransmitLostPackets(decodeRetransmitRequest(data), rinfo);
|
|
187
|
-
else
|
|
188
|
-
}
|
|
364
|
+
else this.#appContext.logger.debug("[raop-control]", "Received unhandled control data from", rinfo, data);
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* Resends lost packets requested by the receiver. For each requested
|
|
368
|
+
* sequence number, sends the original packet wrapped in a retransmit
|
|
369
|
+
* response (type 0xD6). If the packet is no longer in the backlog,
|
|
370
|
+
* sends an empty futile response to acknowledge the request.
|
|
371
|
+
*
|
|
372
|
+
* @param request - Parsed retransmit request with starting sequence number and count.
|
|
373
|
+
* @param addr - Remote address to send retransmitted packets to.
|
|
374
|
+
*/
|
|
189
375
|
#retransmitLostPackets(request, addr) {
|
|
190
376
|
for (let i = 0; i < request.lostPackets; i++) {
|
|
191
377
|
const seqno = request.lostSeqno + i & 65535;
|
|
@@ -199,16 +385,32 @@ var ControlClient = class extends EventEmitter {
|
|
|
199
385
|
packet
|
|
200
386
|
]);
|
|
201
387
|
if (this.#transport) this.#transport.send(resp, addr.port, addr.address);
|
|
202
|
-
} else
|
|
388
|
+
} else {
|
|
389
|
+
const seqBuf = Buffer.alloc(2);
|
|
390
|
+
seqBuf.writeUInt16BE(seqno);
|
|
391
|
+
const resp = Buffer.concat([
|
|
392
|
+
Buffer.from([128, 214]),
|
|
393
|
+
seqBuf,
|
|
394
|
+
Buffer.alloc(4)
|
|
395
|
+
]);
|
|
396
|
+
if (this.#transport) this.#transport.send(resp, addr.port, addr.address);
|
|
397
|
+
}
|
|
203
398
|
}
|
|
204
399
|
}
|
|
205
400
|
};
|
|
206
401
|
|
|
207
402
|
//#endregion
|
|
208
403
|
//#region src/rtspClient.ts
|
|
404
|
+
/** User-Agent header value sent with all RTSP requests. */
|
|
209
405
|
const USER_AGENT = "AirPlay/550.10";
|
|
406
|
+
/** Number of audio frames per RTP packet, used in SDP ANNOUNCE payload. */
|
|
210
407
|
const FRAMES_PER_PACKET$1 = 352;
|
|
408
|
+
/** Single-byte flag indicating unencrypted auth-setup mode. */
|
|
211
409
|
const AUTH_SETUP_UNENCRYPTED = Buffer.from([1]);
|
|
410
|
+
/**
|
|
411
|
+
* Static Curve25519 public key sent during `/auth-setup` for
|
|
412
|
+
* MFi-SAP authentication with AirPort Express devices.
|
|
413
|
+
*/
|
|
212
414
|
const CURVE25519_PUB_KEY = Buffer.from([
|
|
213
415
|
89,
|
|
214
416
|
2,
|
|
@@ -243,12 +445,27 @@ const CURVE25519_PUB_KEY = Buffer.from([
|
|
|
243
445
|
29,
|
|
244
446
|
78
|
|
245
447
|
]);
|
|
448
|
+
/**
|
|
449
|
+
* Computes an HTTP Digest authentication header value using MD5.
|
|
450
|
+
*
|
|
451
|
+
* @param method - RTSP method (may be empty for default headers).
|
|
452
|
+
* @param uri - Request URI.
|
|
453
|
+
* @param info - Digest credentials and challenge parameters.
|
|
454
|
+
* @returns Formatted Digest authorization header value.
|
|
455
|
+
*/
|
|
246
456
|
function getDigestPayload(method, uri, info) {
|
|
247
457
|
const ha1 = createHash("md5").update(`${info.username}:${info.realm}:${info.password}`).digest("hex");
|
|
248
458
|
const ha2 = createHash("md5").update(`${method}:${uri}`).digest("hex");
|
|
249
459
|
const response = createHash("md5").update(`${ha1}:${info.nonce}:${ha2}`).digest("hex");
|
|
250
460
|
return `Digest username="${info.username}", realm="${info.realm}", nonce="${info.nonce}", uri="${uri}", response="${response}"`;
|
|
251
461
|
}
|
|
462
|
+
/**
|
|
463
|
+
* Builds an SDP (Session Description Protocol) body for the RTSP
|
|
464
|
+
* ANNOUNCE request, describing the audio format and codec parameters.
|
|
465
|
+
*
|
|
466
|
+
* @param options - Audio format and connection details.
|
|
467
|
+
* @returns SDP payload string with CRLF line endings.
|
|
468
|
+
*/
|
|
252
469
|
function buildAnnouncePayload(options) {
|
|
253
470
|
return [
|
|
254
471
|
"v=0",
|
|
@@ -261,44 +478,70 @@ function buildAnnouncePayload(options) {
|
|
|
261
478
|
`a=fmtp:96 ${FRAMES_PER_PACKET$1} 0 ${options.bitsPerChannel} 40 10 14 ${options.channels} 255 0 0 ${options.sampleRate}`
|
|
262
479
|
].join("\r\n") + "\r\n";
|
|
263
480
|
}
|
|
481
|
+
/**
|
|
482
|
+
* RAOP-specific RTSP client that extends the base RTSP client with
|
|
483
|
+
* Apple audio streaming commands. Handles the full RAOP RTSP lifecycle:
|
|
484
|
+
* ANNOUNCE, SETUP, RECORD, SET_PARAMETER, FLUSH, TEARDOWN, as well as
|
|
485
|
+
* authentication (auth-setup, digest) and metadata/artwork publishing.
|
|
486
|
+
*/
|
|
264
487
|
var RaopRtspClient = class extends RtspClient {
|
|
488
|
+
/** Active-Remote identifier used for DACP remote control pairing. */
|
|
265
489
|
get activeRemoteId() {
|
|
266
490
|
return this.#activeRemoteId;
|
|
267
491
|
}
|
|
492
|
+
/** DACP identifier used for remote control discovery. */
|
|
268
493
|
get dacpId() {
|
|
269
494
|
return this.#dacpId;
|
|
270
495
|
}
|
|
496
|
+
/** RTSP session identifier string included in session-scoped requests. */
|
|
271
497
|
get rtspSessionId() {
|
|
272
498
|
return this.#rtspSessionId;
|
|
273
499
|
}
|
|
500
|
+
/** Numeric session identifier used in the RTSP URI path. */
|
|
274
501
|
get sessionId() {
|
|
275
502
|
return this.#sessionId;
|
|
276
503
|
}
|
|
504
|
+
/** RTSP URI for this session, formatted as `rtsp://<localIp>/<sessionId>`. */
|
|
277
505
|
get uri() {
|
|
278
506
|
return `rtsp://${this.connection.localIp}/${this.#sessionId}`;
|
|
279
507
|
}
|
|
508
|
+
/** Local and remote IP addresses of the RTSP connection. */
|
|
280
509
|
get connection() {
|
|
281
510
|
return {
|
|
282
|
-
localIp: this
|
|
511
|
+
localIp: this.localAddress,
|
|
283
512
|
remoteIp: this.address
|
|
284
513
|
};
|
|
285
514
|
}
|
|
515
|
+
/** Generated Active-Remote identifier for DACP. */
|
|
286
516
|
#activeRemoteId;
|
|
517
|
+
/** Generated DACP identifier for remote control. */
|
|
287
518
|
#dacpId;
|
|
519
|
+
/** Generated RTSP session identifier. */
|
|
288
520
|
#rtspSessionId;
|
|
521
|
+
/** Random numeric session identifier for the RTSP URI. */
|
|
289
522
|
#sessionId;
|
|
290
|
-
|
|
523
|
+
/** Digest authentication credentials, set after a 401 challenge. */
|
|
291
524
|
#digestInfo;
|
|
525
|
+
/**
|
|
526
|
+
* Creates a new RAOP RTSP client and generates unique session identifiers.
|
|
527
|
+
*
|
|
528
|
+
* @param context - Application context for logging and device identity.
|
|
529
|
+
* @param address - IP address of the RAOP receiver.
|
|
530
|
+
* @param port - RTSP port of the RAOP receiver.
|
|
531
|
+
*/
|
|
292
532
|
constructor(context, address, port) {
|
|
293
533
|
super(context, address, port);
|
|
294
534
|
this.#activeRemoteId = generateActiveRemoteId();
|
|
295
535
|
this.#dacpId = generateDacpId();
|
|
296
536
|
this.#rtspSessionId = generateSessionId();
|
|
297
537
|
this.#sessionId = Math.floor(Math.random() * 4294967295);
|
|
298
|
-
this.on("connect", () => {
|
|
299
|
-
this.#localIp = "0.0.0.0";
|
|
300
|
-
});
|
|
301
538
|
}
|
|
539
|
+
/**
|
|
540
|
+
* Returns default headers included with every RTSP request, including
|
|
541
|
+
* DACP identifiers, user-agent, and digest authorization if available.
|
|
542
|
+
*
|
|
543
|
+
* @returns Header key-value pairs.
|
|
544
|
+
*/
|
|
302
545
|
getDefaultHeaders() {
|
|
303
546
|
const headers = {
|
|
304
547
|
"DACP-ID": this.#dacpId,
|
|
@@ -309,6 +552,12 @@ var RaopRtspClient = class extends RtspClient {
|
|
|
309
552
|
if (this.#digestInfo) headers["Authorization"] = getDigestPayload("", this.uri, this.#digestInfo);
|
|
310
553
|
return headers;
|
|
311
554
|
}
|
|
555
|
+
/**
|
|
556
|
+
* Fetches device information from the `/info` endpoint. Returns the
|
|
557
|
+
* parsed plist response as a dictionary, or an empty object on failure.
|
|
558
|
+
*
|
|
559
|
+
* @returns Device info dictionary, or empty object if unavailable.
|
|
560
|
+
*/
|
|
312
561
|
async info() {
|
|
313
562
|
try {
|
|
314
563
|
const response = await this.exchange("GET", "/info", { allowError: true });
|
|
@@ -327,6 +576,11 @@ var RaopRtspClient = class extends RtspClient {
|
|
|
327
576
|
return {};
|
|
328
577
|
}
|
|
329
578
|
}
|
|
579
|
+
/**
|
|
580
|
+
* Performs MFi-SAP authentication setup by sending a Curve25519 public
|
|
581
|
+
* key to `/auth-setup`. Required for AirPort Express devices that use
|
|
582
|
+
* MFi-SAP encryption.
|
|
583
|
+
*/
|
|
330
584
|
async authSetup() {
|
|
331
585
|
const body = Buffer.concat([AUTH_SETUP_UNENCRYPTED, CURVE25519_PUB_KEY]);
|
|
332
586
|
await this.exchange("POST", "/auth-setup", {
|
|
@@ -335,6 +589,17 @@ var RaopRtspClient = class extends RtspClient {
|
|
|
335
589
|
protocol: "HTTP/1.1"
|
|
336
590
|
});
|
|
337
591
|
}
|
|
592
|
+
/**
|
|
593
|
+
* Sends an RTSP ANNOUNCE request with an SDP body describing the audio
|
|
594
|
+
* format. If the receiver responds with a 401 challenge and a password
|
|
595
|
+
* is provided, retries with HTTP Digest authentication.
|
|
596
|
+
*
|
|
597
|
+
* @param bytesPerChannel - Bytes per audio channel sample (e.g. 2 for 16-bit).
|
|
598
|
+
* @param channels - Number of audio channels.
|
|
599
|
+
* @param sampleRate - Audio sample rate in Hz.
|
|
600
|
+
* @param password - Optional password for digest authentication.
|
|
601
|
+
* @returns The RTSP response.
|
|
602
|
+
*/
|
|
338
603
|
async announce(bytesPerChannel, channels, sampleRate, password) {
|
|
339
604
|
const body = buildAnnouncePayload({
|
|
340
605
|
sessionId: this.#sessionId,
|
|
@@ -369,24 +634,60 @@ var RaopRtspClient = class extends RtspClient {
|
|
|
369
634
|
}
|
|
370
635
|
return response;
|
|
371
636
|
}
|
|
637
|
+
/**
|
|
638
|
+
* Sends an RTSP SETUP request to negotiate transport parameters
|
|
639
|
+
* (ports, protocol) with the receiver.
|
|
640
|
+
*
|
|
641
|
+
* @param headers - Optional additional headers (e.g. Transport).
|
|
642
|
+
* @param body - Optional request body.
|
|
643
|
+
* @returns The RTSP response containing server-assigned ports in the Transport header.
|
|
644
|
+
*/
|
|
372
645
|
async setup(headers, body) {
|
|
373
646
|
return await this.exchange("SETUP", this.uri, {
|
|
374
647
|
headers,
|
|
375
648
|
body
|
|
376
649
|
});
|
|
377
650
|
}
|
|
651
|
+
/**
|
|
652
|
+
* Sends an RTSP RECORD request to begin audio playback on the receiver.
|
|
653
|
+
* Includes RTP-Info and Range headers for stream positioning.
|
|
654
|
+
*
|
|
655
|
+
* @param headers - Optional headers (typically Range, Session, RTP-Info).
|
|
656
|
+
*/
|
|
378
657
|
async record(headers) {
|
|
379
658
|
await this.exchange("RECORD", this.uri, { headers });
|
|
380
659
|
}
|
|
660
|
+
/**
|
|
661
|
+
* Sends an RTSP FLUSH request to clear the receiver's audio buffer
|
|
662
|
+
* and reset playback to the specified RTP position.
|
|
663
|
+
*
|
|
664
|
+
* @param options - Headers including Session and RTP-Info for flush positioning.
|
|
665
|
+
*/
|
|
381
666
|
async flush(options) {
|
|
382
667
|
await this.exchange("FLUSH", this.uri, { headers: options.headers });
|
|
383
668
|
}
|
|
669
|
+
/**
|
|
670
|
+
* Sends a SET_PARAMETER request with a text/parameters content type.
|
|
671
|
+
* Used for setting volume, progress, and other scalar parameters.
|
|
672
|
+
*
|
|
673
|
+
* @param name - Parameter name (e.g. "volume", "progress").
|
|
674
|
+
* @param value - Parameter value as a string.
|
|
675
|
+
*/
|
|
384
676
|
async setParameter(name, value) {
|
|
385
677
|
await this.exchange("SET_PARAMETER", this.uri, {
|
|
386
678
|
contentType: "text/parameters",
|
|
387
679
|
body: `${name}: ${value}`
|
|
388
680
|
});
|
|
389
681
|
}
|
|
682
|
+
/**
|
|
683
|
+
* Sends track metadata (title, artist, album, duration) to the receiver
|
|
684
|
+
* as DAAP-tagged data via SET_PARAMETER.
|
|
685
|
+
*
|
|
686
|
+
* @param session - RTSP session identifier.
|
|
687
|
+
* @param rtpseq - Current RTP sequence number for the RTP-Info header.
|
|
688
|
+
* @param rtptime - Current RTP timestamp for the RTP-Info header.
|
|
689
|
+
* @param metadata - Track metadata to send.
|
|
690
|
+
*/
|
|
390
691
|
async setMetadata(session, rtpseq, rtptime, metadata) {
|
|
391
692
|
const daapData = DAAP.encodeTrackMetadata({
|
|
392
693
|
title: metadata.title,
|
|
@@ -403,6 +704,15 @@ var RaopRtspClient = class extends RtspClient {
|
|
|
403
704
|
body: daapData
|
|
404
705
|
});
|
|
405
706
|
}
|
|
707
|
+
/**
|
|
708
|
+
* Sends album artwork to the receiver via SET_PARAMETER. Automatically
|
|
709
|
+
* detects PNG images by magic bytes, defaulting to JPEG otherwise.
|
|
710
|
+
*
|
|
711
|
+
* @param session - RTSP session identifier.
|
|
712
|
+
* @param rtpseq - Current RTP sequence number for the RTP-Info header.
|
|
713
|
+
* @param rtptime - Current RTP timestamp for the RTP-Info header.
|
|
714
|
+
* @param artwork - Image data buffer (JPEG or PNG).
|
|
715
|
+
*/
|
|
406
716
|
async setArtwork(session, rtpseq, rtptime, artwork) {
|
|
407
717
|
let contentType = "image/jpeg";
|
|
408
718
|
if (artwork[0] === 137 && artwork[1] === 80) contentType = "image/png";
|
|
@@ -415,9 +725,22 @@ var RaopRtspClient = class extends RtspClient {
|
|
|
415
725
|
body: artwork
|
|
416
726
|
});
|
|
417
727
|
}
|
|
728
|
+
/**
|
|
729
|
+
* Sends a feedback request to `/feedback` to keep the session alive.
|
|
730
|
+
* Typically called periodically during active streaming.
|
|
731
|
+
*
|
|
732
|
+
* @param allowError - Whether to suppress HTTP error responses.
|
|
733
|
+
* @returns The feedback response.
|
|
734
|
+
*/
|
|
418
735
|
async feedback(allowError = false) {
|
|
419
736
|
return await this.exchange("POST", "/feedback", { allowError });
|
|
420
737
|
}
|
|
738
|
+
/**
|
|
739
|
+
* Sends an RTSP TEARDOWN request to end the streaming session
|
|
740
|
+
* and release server-side resources.
|
|
741
|
+
*
|
|
742
|
+
* @param session - RTSP session identifier to tear down.
|
|
743
|
+
*/
|
|
421
744
|
async teardown(session) {
|
|
422
745
|
await this.exchange("TEARDOWN", this.uri, { headers: { "Session": session } });
|
|
423
746
|
}
|
|
@@ -425,31 +748,69 @@ var RaopRtspClient = class extends RtspClient {
|
|
|
425
748
|
|
|
426
749
|
//#endregion
|
|
427
750
|
//#region src/statistics.ts
|
|
751
|
+
/**
|
|
752
|
+
* Tracks real-time streaming statistics to detect when audio packet
|
|
753
|
+
* sending falls behind wall-clock time. Uses high-resolution monotonic
|
|
754
|
+
* timers to compare actual frames sent against expected frame count.
|
|
755
|
+
*/
|
|
428
756
|
var Statistics = class {
|
|
757
|
+
/** Audio sample rate in Hz, used to compute expected frame counts. */
|
|
429
758
|
sampleRate;
|
|
759
|
+
/** High-resolution monotonic timestamp (nanoseconds) captured at construction. */
|
|
430
760
|
startTimeNs;
|
|
761
|
+
/** Millisecond timestamp marking the start of the current reporting interval. */
|
|
431
762
|
intervalTime;
|
|
763
|
+
/** Total number of audio frames sent since streaming began. */
|
|
432
764
|
totalFrames = 0;
|
|
765
|
+
/** Number of audio frames sent within the current reporting interval. */
|
|
433
766
|
intervalFrames = 0;
|
|
767
|
+
/**
|
|
768
|
+
* Creates a new Statistics tracker, capturing the current time as baseline.
|
|
769
|
+
*
|
|
770
|
+
* @param sampleRate - Audio sample rate in Hz (e.g. 44100).
|
|
771
|
+
*/
|
|
434
772
|
constructor(sampleRate) {
|
|
435
773
|
this.sampleRate = sampleRate;
|
|
436
774
|
this.startTimeNs = process.hrtime.bigint();
|
|
437
775
|
this.intervalTime = performance.now();
|
|
438
776
|
}
|
|
777
|
+
/**
|
|
778
|
+
* Computes how many audio frames should have been sent by now,
|
|
779
|
+
* based on elapsed wall-clock time since streaming started.
|
|
780
|
+
*/
|
|
439
781
|
get expectedFrameCount() {
|
|
440
782
|
const elapsedNs = Number(process.hrtime.bigint() - this.startTimeNs);
|
|
441
783
|
return Math.floor(elapsedNs / (1e9 / this.sampleRate));
|
|
442
784
|
}
|
|
785
|
+
/**
|
|
786
|
+
* Number of frames the sender is lagging behind real-time.
|
|
787
|
+
* A positive value means packets need to be sent faster.
|
|
788
|
+
*/
|
|
443
789
|
get framesBehind() {
|
|
444
790
|
return this.expectedFrameCount - this.totalFrames;
|
|
445
791
|
}
|
|
792
|
+
/**
|
|
793
|
+
* Whether the current interval has accumulated at least one second
|
|
794
|
+
* worth of frames, indicating it is time to log and reset.
|
|
795
|
+
*/
|
|
446
796
|
get intervalCompleted() {
|
|
447
797
|
return this.intervalFrames >= this.sampleRate;
|
|
448
798
|
}
|
|
799
|
+
/**
|
|
800
|
+
* Records that additional audio frames have been sent.
|
|
801
|
+
*
|
|
802
|
+
* @param sentFrames - Number of frames just sent.
|
|
803
|
+
*/
|
|
449
804
|
tick(sentFrames) {
|
|
450
805
|
this.totalFrames += sentFrames;
|
|
451
806
|
this.intervalFrames += sentFrames;
|
|
452
807
|
}
|
|
808
|
+
/**
|
|
809
|
+
* Completes the current reporting interval, resetting the interval
|
|
810
|
+
* frame counter and returning timing information for logging.
|
|
811
|
+
*
|
|
812
|
+
* @returns A tuple of [elapsed seconds, frames sent] for the completed interval.
|
|
813
|
+
*/
|
|
453
814
|
newInterval() {
|
|
454
815
|
const endTime = performance.now();
|
|
455
816
|
const diff = (endTime - this.intervalTime) / 1e3;
|
|
@@ -462,51 +823,109 @@ var Statistics = class {
|
|
|
462
823
|
|
|
463
824
|
//#endregion
|
|
464
825
|
//#region src/const.ts
|
|
826
|
+
/**
|
|
827
|
+
* Number of recently sent packets to keep in the FIFO backlog
|
|
828
|
+
* for retransmission upon receiver request.
|
|
829
|
+
*/
|
|
465
830
|
const PACKET_BACKLOG_SIZE = 1e3;
|
|
831
|
+
/**
|
|
832
|
+
* Fallback metadata used when the caller does not provide any
|
|
833
|
+
* media metadata for the audio stream.
|
|
834
|
+
*/
|
|
466
835
|
const MISSING_METADATA = {
|
|
467
836
|
title: "Streaming with apple-raop",
|
|
468
837
|
artist: "apple-raop",
|
|
469
838
|
album: "AirPlay",
|
|
470
839
|
duration: 0
|
|
471
840
|
};
|
|
841
|
+
/**
|
|
842
|
+
* Empty metadata sentinel used as the default value before any
|
|
843
|
+
* metadata has been set on a stream.
|
|
844
|
+
*/
|
|
472
845
|
const EMPTY_METADATA = {
|
|
473
846
|
title: "",
|
|
474
847
|
artist: "",
|
|
475
848
|
album: "",
|
|
476
849
|
duration: 0
|
|
477
850
|
};
|
|
851
|
+
/**
|
|
852
|
+
* Bitmask of encryption types this client supports.
|
|
853
|
+
* Currently supports unencrypted and MFi-SAP (AirPort Express) encryption.
|
|
854
|
+
*/
|
|
478
855
|
const SUPPORTED_ENCRYPTIONS = EncryptionType.Unencrypted | EncryptionType.MFiSAP;
|
|
479
856
|
|
|
480
857
|
//#endregion
|
|
481
858
|
//#region src/streamClient.ts
|
|
859
|
+
/**
|
|
860
|
+
* Core streaming engine for RAOP audio. Manages the complete audio streaming
|
|
861
|
+
* lifecycle: initialization (encryption/metadata negotiation, control channel
|
|
862
|
+
* setup), real-time PCM packet sending with timing compensation, metadata
|
|
863
|
+
* and artwork publishing, and teardown.
|
|
864
|
+
*
|
|
865
|
+
* Uses a Statistics tracker to maintain real-time pacing and compensates
|
|
866
|
+
* for slow packet sending by bursting additional packets when falling behind.
|
|
867
|
+
*/
|
|
482
868
|
var StreamClient = class extends EventEmitter {
|
|
869
|
+
/** Device info dictionary fetched from the receiver during initialization. */
|
|
483
870
|
get info() {
|
|
484
871
|
return this.#info;
|
|
485
872
|
}
|
|
873
|
+
/**
|
|
874
|
+
* Current playback info snapshot, substituting fallback metadata
|
|
875
|
+
* if the caller provided no metadata.
|
|
876
|
+
*/
|
|
486
877
|
get playbackInfo() {
|
|
487
878
|
return {
|
|
488
879
|
metadata: this.#isMetadataEmpty(this.#metadata) ? MISSING_METADATA : this.#metadata,
|
|
489
880
|
position: this.#streamContext.position
|
|
490
881
|
};
|
|
491
882
|
}
|
|
883
|
+
/**
|
|
884
|
+
* Whether MFi-SAP auth-setup is required, determined by the device's
|
|
885
|
+
* supported encryption types and model name (AirPort devices only).
|
|
886
|
+
*/
|
|
492
887
|
get #requiresAuthSetup() {
|
|
493
888
|
const modelName = this.#properties.get("am") ?? "";
|
|
494
889
|
return (this.#encryptionTypes & EncryptionType.MFiSAP) !== 0 && modelName.startsWith("AirPort");
|
|
495
890
|
}
|
|
891
|
+
/** Application context for logging. */
|
|
496
892
|
#context;
|
|
893
|
+
/** RAOP RTSP client for protocol commands. */
|
|
497
894
|
#rtsp;
|
|
895
|
+
/** Shared mutable streaming state. */
|
|
498
896
|
#streamContext;
|
|
897
|
+
/** Port configuration settings. */
|
|
499
898
|
#settings;
|
|
899
|
+
/** Protocol abstraction for transport-level operations. */
|
|
500
900
|
#protocol;
|
|
901
|
+
/** FIFO backlog of recently sent packets for retransmission. */
|
|
501
902
|
#packetBacklog;
|
|
903
|
+
/** NTP timing server for clock synchronization with the receiver. */
|
|
502
904
|
#timingServer;
|
|
905
|
+
/** UDP control channel client, created during initialization. */
|
|
503
906
|
#controlClient;
|
|
907
|
+
/** Bitmask of receiver-supported encryption types. */
|
|
504
908
|
#encryptionTypes = EncryptionType.Unknown;
|
|
909
|
+
/** Bitmask of receiver-supported metadata types. */
|
|
505
910
|
#metadataTypes = MetadataType.NotSupported;
|
|
911
|
+
/** Current track metadata. */
|
|
506
912
|
#metadata = EMPTY_METADATA;
|
|
913
|
+
/** Device info dictionary from the receiver. */
|
|
507
914
|
#info = {};
|
|
915
|
+
/** mDNS TXT record properties for the device. */
|
|
508
916
|
#properties = /* @__PURE__ */ new Map();
|
|
917
|
+
/** Flag controlling the streaming loop; set to false to stop. */
|
|
509
918
|
#isPlaying = false;
|
|
919
|
+
/**
|
|
920
|
+
* Creates a new stream client.
|
|
921
|
+
*
|
|
922
|
+
* @param context - Application context for logging.
|
|
923
|
+
* @param rtsp - Connected RAOP RTSP client.
|
|
924
|
+
* @param streamContext - Shared mutable streaming state.
|
|
925
|
+
* @param protocol - Protocol handler for transport operations.
|
|
926
|
+
* @param settings - Port configuration settings.
|
|
927
|
+
* @param timingServer - NTP timing server.
|
|
928
|
+
*/
|
|
510
929
|
constructor(context, rtsp, streamContext, protocol, settings, timingServer) {
|
|
511
930
|
super();
|
|
512
931
|
this.#context = context;
|
|
@@ -517,9 +936,19 @@ var StreamClient = class extends EventEmitter {
|
|
|
517
936
|
this.#packetBacklog = new PacketFifo(PACKET_BACKLOG_SIZE);
|
|
518
937
|
this.#timingServer = timingServer;
|
|
519
938
|
}
|
|
939
|
+
/**
|
|
940
|
+
* Closes the control channel, releasing its UDP socket.
|
|
941
|
+
*/
|
|
520
942
|
close() {
|
|
521
943
|
this.#controlClient?.close();
|
|
522
944
|
}
|
|
945
|
+
/**
|
|
946
|
+
* Initializes the streaming session by parsing device capabilities,
|
|
947
|
+
* binding the control channel, fetching device info, performing
|
|
948
|
+
* auth-setup if required, and running the protocol SETUP handshake.
|
|
949
|
+
*
|
|
950
|
+
* @param properties - mDNS TXT record key-value pairs for the device.
|
|
951
|
+
*/
|
|
523
952
|
async initialize(properties) {
|
|
524
953
|
this.#properties = properties;
|
|
525
954
|
this.#encryptionTypes = getEncryptionTypes(properties);
|
|
@@ -528,7 +957,7 @@ var StreamClient = class extends EventEmitter {
|
|
|
528
957
|
const intersection = this.#encryptionTypes & SUPPORTED_ENCRYPTIONS;
|
|
529
958
|
if (!intersection || intersection === EncryptionType.Unknown) this.#context.logger.debug("No supported encryption type, continuing anyway");
|
|
530
959
|
this.#updateOutputProperties(properties);
|
|
531
|
-
this.#controlClient = new ControlClient(this.#streamContext, this.#packetBacklog);
|
|
960
|
+
this.#controlClient = new ControlClient(this.#context, this.#streamContext, this.#packetBacklog);
|
|
532
961
|
await this.#controlClient.bind(this.#rtsp.connection.localIp, this.#settings.protocols.raop.controlPort);
|
|
533
962
|
this.#context.logger.debug(`Local ports: control=${this.#controlClient.port}, timing=${this.#timingServer.port}`);
|
|
534
963
|
const info = await this.#rtsp.info();
|
|
@@ -537,14 +966,36 @@ var StreamClient = class extends EventEmitter {
|
|
|
537
966
|
if (this.#requiresAuthSetup) await this.#rtsp.authSetup();
|
|
538
967
|
await this.#protocol.setup(this.#timingServer.port, this.#controlClient.port);
|
|
539
968
|
}
|
|
969
|
+
/**
|
|
970
|
+
* Signals the streaming loop to stop after the current packet.
|
|
971
|
+
* The `sendAudio()` call will return after teardown completes.
|
|
972
|
+
*/
|
|
540
973
|
stop() {
|
|
541
974
|
this.#context.logger.debug("Stopping audio playback");
|
|
542
975
|
this.#isPlaying = false;
|
|
543
976
|
}
|
|
977
|
+
/**
|
|
978
|
+
* Changes the playback volume on the receiver via RTSP SET_PARAMETER.
|
|
979
|
+
*
|
|
980
|
+
* @param volume - Volume level in dBFS.
|
|
981
|
+
*/
|
|
544
982
|
async setVolume(volume) {
|
|
545
983
|
await this.#rtsp.setParameter("volume", String(volume));
|
|
546
984
|
this.#streamContext.volume = volume;
|
|
547
985
|
}
|
|
986
|
+
/**
|
|
987
|
+
* Main entry point for streaming audio. Resets the stream context,
|
|
988
|
+
* connects the UDP audio transport, publishes metadata/artwork/progress,
|
|
989
|
+
* starts the RTSP RECORD session, and enters the real-time streaming loop.
|
|
990
|
+
*
|
|
991
|
+
* On completion or error, performs full teardown: clears the packet backlog,
|
|
992
|
+
* sends RTSP TEARDOWN, closes the UDP transport, and emits 'stopped'.
|
|
993
|
+
*
|
|
994
|
+
* @param source - Audio source providing PCM frames to stream.
|
|
995
|
+
* @param metadata - Track metadata to display on the receiver.
|
|
996
|
+
* @param volume - Optional initial volume as a percentage (0-100), converted to dBFS.
|
|
997
|
+
* @throws When streaming encounters an unrecoverable error.
|
|
998
|
+
*/
|
|
548
999
|
async sendAudio(source, metadata = EMPTY_METADATA, volume) {
|
|
549
1000
|
if (!this.#controlClient) throw new Error("Not initialized");
|
|
550
1001
|
this.#streamContext.reset();
|
|
@@ -598,6 +1049,16 @@ var StreamClient = class extends EventEmitter {
|
|
|
598
1049
|
this.emit("stopped");
|
|
599
1050
|
}
|
|
600
1051
|
}
|
|
1052
|
+
/**
|
|
1053
|
+
* Real-time audio streaming loop. Reads PCM frames from the source,
|
|
1054
|
+
* sends them as RTP packets over UDP, and uses wall-clock timing to
|
|
1055
|
+
* maintain real-time pacing. When falling behind, sends compensating
|
|
1056
|
+
* burst packets (up to MAX_PACKETS_COMPENSATE). Logs periodic
|
|
1057
|
+
* throughput statistics and slow-sender warnings.
|
|
1058
|
+
*
|
|
1059
|
+
* @param source - Audio source to read frames from.
|
|
1060
|
+
* @param transport - UDP socket connected to the receiver's audio port.
|
|
1061
|
+
*/
|
|
601
1062
|
async #streamData(source, transport) {
|
|
602
1063
|
const stats = new Statistics(this.#streamContext.sampleRate);
|
|
603
1064
|
const initialTime = performance.now();
|
|
@@ -637,6 +1098,17 @@ var StreamClient = class extends EventEmitter {
|
|
|
637
1098
|
const elapsedNs = Number(process.hrtime.bigint() - stats.startTimeNs);
|
|
638
1099
|
this.#context.logger.debug(`Audio finished sending in ${(elapsedNs / 1e9).toFixed(3)}s`);
|
|
639
1100
|
}
|
|
1101
|
+
/**
|
|
1102
|
+
* Reads one packet's worth of audio frames from the source and sends
|
|
1103
|
+
* it as an RTP packet. If the source is exhausted, sends silence
|
|
1104
|
+
* padding until the latency buffer is filled. Undersized frames
|
|
1105
|
+
* are zero-padded to the expected packet size.
|
|
1106
|
+
*
|
|
1107
|
+
* @param source - Audio source to read frames from.
|
|
1108
|
+
* @param firstPacket - Whether this is the first packet (uses marker payload type 0xE0).
|
|
1109
|
+
* @param transport - UDP socket connected to the receiver's audio port.
|
|
1110
|
+
* @returns Number of audio frames sent, or 0 when padding is exhausted.
|
|
1111
|
+
*/
|
|
640
1112
|
async #sendPacket(source, firstPacket, transport) {
|
|
641
1113
|
if (this.#streamContext.paddingSent >= this.#streamContext.latency) return 0;
|
|
642
1114
|
let frames = await source.readFrames(352);
|
|
@@ -652,9 +1124,18 @@ var StreamClient = class extends EventEmitter {
|
|
|
652
1124
|
const [rtpseq, packet] = await this.#protocol.sendAudioPacket(transport, header, frames);
|
|
653
1125
|
this.#packetBacklog.set(rtpseq, packet);
|
|
654
1126
|
this.#streamContext.rtpseq = (this.#streamContext.rtpseq + 1) % 2 ** 16;
|
|
655
|
-
this.#streamContext.headTs
|
|
1127
|
+
this.#streamContext.headTs = this.#streamContext.headTs + Math.floor(frames.length / this.#streamContext.frameSize) >>> 0;
|
|
656
1128
|
return Math.floor(frames.length / this.#streamContext.frameSize);
|
|
657
1129
|
}
|
|
1130
|
+
/**
|
|
1131
|
+
* Sends multiple packets in a burst to compensate for falling behind
|
|
1132
|
+
* real-time playback. Stops early if the source is exhausted.
|
|
1133
|
+
*
|
|
1134
|
+
* @param source - Audio source to read frames from.
|
|
1135
|
+
* @param transport - UDP socket connected to the receiver's audio port.
|
|
1136
|
+
* @param count - Number of packets to send.
|
|
1137
|
+
* @returns A tuple of [total frames sent, whether more packets are available].
|
|
1138
|
+
*/
|
|
658
1139
|
async #sendNumberOfPackets(source, transport, count) {
|
|
659
1140
|
let totalFrames = 0;
|
|
660
1141
|
for (let i = 0; i < count; i++) {
|
|
@@ -664,9 +1145,22 @@ var StreamClient = class extends EventEmitter {
|
|
|
664
1145
|
}
|
|
665
1146
|
return [totalFrames, true];
|
|
666
1147
|
}
|
|
1148
|
+
/**
|
|
1149
|
+
* Checks whether the given metadata has all empty/default values,
|
|
1150
|
+
* indicating no real metadata was provided by the caller.
|
|
1151
|
+
*
|
|
1152
|
+
* @param metadata - Metadata to check.
|
|
1153
|
+
* @returns True if all fields are empty or zero.
|
|
1154
|
+
*/
|
|
667
1155
|
#isMetadataEmpty(metadata) {
|
|
668
1156
|
return metadata.title === "" && metadata.artist === "" && metadata.album === "" && metadata.duration === 0;
|
|
669
1157
|
}
|
|
1158
|
+
/**
|
|
1159
|
+
* Updates the stream context audio format from mDNS TXT record
|
|
1160
|
+
* properties (sample rate, channels, bytes per channel).
|
|
1161
|
+
*
|
|
1162
|
+
* @param properties - mDNS TXT record key-value pairs.
|
|
1163
|
+
*/
|
|
670
1164
|
#updateOutputProperties(properties) {
|
|
671
1165
|
const [sampleRate, channels, bytesPerChannel] = getAudioProperties(properties);
|
|
672
1166
|
this.#streamContext.sampleRate = sampleRate;
|
|
@@ -678,30 +1172,58 @@ var StreamClient = class extends EventEmitter {
|
|
|
678
1172
|
|
|
679
1173
|
//#endregion
|
|
680
1174
|
//#region src/raop.ts
|
|
1175
|
+
/** Default audio sample rate in Hz (CD quality). */
|
|
681
1176
|
const SAMPLE_RATE = 44100;
|
|
1177
|
+
/** Default number of audio channels (stereo). */
|
|
682
1178
|
const CHANNELS = 2;
|
|
1179
|
+
/** Default bytes per channel sample (16-bit). */
|
|
683
1180
|
const BYTES_PER_CHANNEL = 2;
|
|
1181
|
+
/** Number of audio frames per RTP packet. */
|
|
684
1182
|
const FRAMES_PER_PACKET = 352;
|
|
1183
|
+
/**
|
|
1184
|
+
* High-level RAOP client for streaming audio to AirPlay receivers.
|
|
1185
|
+
* Wraps the RTSP handshake, UDP audio transport, control channel,
|
|
1186
|
+
* and metadata publishing into a simple stream/stop/close API.
|
|
1187
|
+
*
|
|
1188
|
+
* Instances are created via the static `create()` or `discover()` methods.
|
|
1189
|
+
*/
|
|
685
1190
|
var RaopClient = class RaopClient extends EventEmitter {
|
|
1191
|
+
/** Application context providing logger and device identity. */
|
|
686
1192
|
get context() {
|
|
687
1193
|
return this.#context;
|
|
688
1194
|
}
|
|
1195
|
+
/** Unique identifier of the discovered RAOP device. */
|
|
689
1196
|
get deviceId() {
|
|
690
1197
|
return this.#discoveryResult.id;
|
|
691
1198
|
}
|
|
1199
|
+
/** IP address of the RAOP receiver. */
|
|
692
1200
|
get address() {
|
|
693
1201
|
return this.#discoveryResult.address;
|
|
694
1202
|
}
|
|
1203
|
+
/** Model name of the RAOP receiver (e.g. "AirPort Express"). */
|
|
695
1204
|
get modelName() {
|
|
696
1205
|
return this.#discoveryResult.modelName;
|
|
697
1206
|
}
|
|
1207
|
+
/** Device info dictionary fetched during initialization. */
|
|
698
1208
|
get info() {
|
|
699
1209
|
return this.#streamClient.info;
|
|
700
1210
|
}
|
|
1211
|
+
/** Application context for logging and identity. */
|
|
701
1212
|
#context;
|
|
1213
|
+
/** RAOP RTSP client for protocol-level commands. */
|
|
702
1214
|
#rtsp;
|
|
1215
|
+
/** Stream client managing audio transport and metadata. */
|
|
703
1216
|
#streamClient;
|
|
1217
|
+
/** mDNS discovery result for this device. */
|
|
704
1218
|
#discoveryResult;
|
|
1219
|
+
/**
|
|
1220
|
+
* Private constructor — use `RaopClient.create()` or `RaopClient.discover()` instead.
|
|
1221
|
+
*
|
|
1222
|
+
* @param context - Application context.
|
|
1223
|
+
* @param rtsp - Connected RTSP client.
|
|
1224
|
+
* @param streamClient - Initialized stream client.
|
|
1225
|
+
* @param discoveryResult - mDNS discovery result for the device.
|
|
1226
|
+
*/
|
|
705
1227
|
constructor(context, rtsp, streamClient, discoveryResult) {
|
|
706
1228
|
super();
|
|
707
1229
|
this.#context = context;
|
|
@@ -711,6 +1233,13 @@ var RaopClient = class RaopClient extends EventEmitter {
|
|
|
711
1233
|
this.#streamClient.on("playing", (info) => this.emit("playing", info));
|
|
712
1234
|
this.#streamClient.on("stopped", () => this.emit("stopped"));
|
|
713
1235
|
}
|
|
1236
|
+
/**
|
|
1237
|
+
* Streams audio from a source to the RAOP receiver. Starts the source,
|
|
1238
|
+
* sends audio data over RTP, and stops the source when finished or on error.
|
|
1239
|
+
*
|
|
1240
|
+
* @param source - Audio source providing PCM frames.
|
|
1241
|
+
* @param options - Optional metadata and volume settings.
|
|
1242
|
+
*/
|
|
714
1243
|
async stream(source, options = {}) {
|
|
715
1244
|
await source.start();
|
|
716
1245
|
try {
|
|
@@ -719,16 +1248,37 @@ var RaopClient = class RaopClient extends EventEmitter {
|
|
|
719
1248
|
await source.stop();
|
|
720
1249
|
}
|
|
721
1250
|
}
|
|
1251
|
+
/**
|
|
1252
|
+
* Signals the stream client to stop sending audio. The current
|
|
1253
|
+
* `stream()` call will complete after flushing remaining data.
|
|
1254
|
+
*/
|
|
722
1255
|
stop() {
|
|
723
1256
|
this.#streamClient.stop();
|
|
724
1257
|
}
|
|
1258
|
+
/**
|
|
1259
|
+
* Changes the playback volume on the receiver.
|
|
1260
|
+
*
|
|
1261
|
+
* @param volume - Volume level in dBFS.
|
|
1262
|
+
*/
|
|
725
1263
|
async setVolume(volume) {
|
|
726
1264
|
await this.#streamClient.setVolume(volume);
|
|
727
1265
|
}
|
|
1266
|
+
/**
|
|
1267
|
+
* Closes all connections and releases resources. Disconnects the
|
|
1268
|
+
* RTSP session and shuts down the control channel.
|
|
1269
|
+
*/
|
|
728
1270
|
async close() {
|
|
729
1271
|
this.#streamClient.close();
|
|
730
1272
|
await this.#rtsp.disconnect();
|
|
731
1273
|
}
|
|
1274
|
+
/**
|
|
1275
|
+
* Creates a new RaopClient from a pre-discovered device. Connects
|
|
1276
|
+
* via RTSP, initializes the stream context, and negotiates transport.
|
|
1277
|
+
*
|
|
1278
|
+
* @param discoveryResult - mDNS discovery result for the target device.
|
|
1279
|
+
* @param timingServer - NTP timing server for clock synchronization.
|
|
1280
|
+
* @returns A fully initialized and connected RaopClient.
|
|
1281
|
+
*/
|
|
732
1282
|
static async create(discoveryResult, timingServer) {
|
|
733
1283
|
const context = new Context(discoveryResult.id);
|
|
734
1284
|
const rtsp = new RaopRtspClient(context, discoveryResult.address, discoveryResult.service.port);
|
|
@@ -743,19 +1293,49 @@ var RaopClient = class RaopClient extends EventEmitter {
|
|
|
743
1293
|
await streamClient.initialize(properties);
|
|
744
1294
|
return new RaopClient(context, rtsp, streamClient, discoveryResult);
|
|
745
1295
|
}
|
|
1296
|
+
/**
|
|
1297
|
+
* Discovers a RAOP device by its device ID via mDNS, then creates
|
|
1298
|
+
* and returns a connected RaopClient.
|
|
1299
|
+
*
|
|
1300
|
+
* @param deviceId - Unique device identifier to search for.
|
|
1301
|
+
* @param timingServer - NTP timing server for clock synchronization.
|
|
1302
|
+
* @returns A fully initialized and connected RaopClient.
|
|
1303
|
+
*/
|
|
746
1304
|
static async discover(deviceId, timingServer) {
|
|
747
1305
|
const result = await Discovery.raop().findUntil(deviceId);
|
|
748
1306
|
return RaopClient.create(result, timingServer);
|
|
749
1307
|
}
|
|
750
1308
|
};
|
|
1309
|
+
/**
|
|
1310
|
+
* RAOP-specific implementation of the StreamProtocol interface.
|
|
1311
|
+
* Handles RTSP ANNOUNCE/SETUP for transport negotiation, periodic
|
|
1312
|
+
* feedback keepalives, and raw UDP audio packet sending.
|
|
1313
|
+
*/
|
|
751
1314
|
var RaopStreamProtocol = class {
|
|
1315
|
+
/** RTSP client for protocol commands. */
|
|
752
1316
|
#rtsp;
|
|
1317
|
+
/** Shared stream context for port and format state. */
|
|
753
1318
|
#streamContext;
|
|
1319
|
+
/** Interval timer for periodic feedback requests. */
|
|
754
1320
|
#feedbackInterval;
|
|
1321
|
+
/**
|
|
1322
|
+
* Creates a new RAOP stream protocol handler.
|
|
1323
|
+
*
|
|
1324
|
+
* @param rtsp - RAOP RTSP client for sending protocol commands.
|
|
1325
|
+
* @param streamContext - Shared mutable streaming state.
|
|
1326
|
+
*/
|
|
755
1327
|
constructor(rtsp, streamContext) {
|
|
756
1328
|
this.#rtsp = rtsp;
|
|
757
1329
|
this.#streamContext = streamContext;
|
|
758
1330
|
}
|
|
1331
|
+
/**
|
|
1332
|
+
* Announces the audio format and sets up the RTP transport by
|
|
1333
|
+
* sending RTSP ANNOUNCE and SETUP requests. Parses the server's
|
|
1334
|
+
* response to extract assigned server and control ports.
|
|
1335
|
+
*
|
|
1336
|
+
* @param timingPort - Local NTP timing server port.
|
|
1337
|
+
* @param controlPort - Local control channel port.
|
|
1338
|
+
*/
|
|
759
1339
|
async setup(timingPort, controlPort) {
|
|
760
1340
|
await this.#rtsp.announce(this.#streamContext.bytesPerChannel, this.#streamContext.channels, this.#streamContext.sampleRate);
|
|
761
1341
|
const transport = [
|
|
@@ -773,6 +1353,10 @@ var RaopStreamProtocol = class {
|
|
|
773
1353
|
const controlPortMatch = transportHeader.match(/control_port=(\d+)/);
|
|
774
1354
|
if (controlPortMatch) this.#streamContext.controlPort = parseInt(controlPortMatch[1], 10);
|
|
775
1355
|
}
|
|
1356
|
+
/**
|
|
1357
|
+
* Starts sending periodic feedback POST requests every 2 seconds
|
|
1358
|
+
* to keep the RAOP session alive during streaming.
|
|
1359
|
+
*/
|
|
776
1360
|
async startFeedback() {
|
|
777
1361
|
this.#feedbackInterval = setInterval(async () => {
|
|
778
1362
|
try {
|
|
@@ -782,6 +1366,15 @@ var RaopStreamProtocol = class {
|
|
|
782
1366
|
}
|
|
783
1367
|
}, 2e3);
|
|
784
1368
|
}
|
|
1369
|
+
/**
|
|
1370
|
+
* Sends a single audio packet over the UDP transport by concatenating
|
|
1371
|
+
* the RTP header with the audio payload.
|
|
1372
|
+
*
|
|
1373
|
+
* @param transport - UDP socket connected to the receiver's audio port.
|
|
1374
|
+
* @param header - 12-byte RTP header.
|
|
1375
|
+
* @param audio - Raw audio payload data.
|
|
1376
|
+
* @returns A tuple of [sequence number, full packet buffer] for backlog storage.
|
|
1377
|
+
*/
|
|
785
1378
|
async sendAudioPacket(transport, header, audio) {
|
|
786
1379
|
const packet = Buffer.concat([header, audio]);
|
|
787
1380
|
const seqno = header.readUInt16BE(2);
|
|
@@ -790,20 +1383,30 @@ var RaopStreamProtocol = class {
|
|
|
790
1383
|
});
|
|
791
1384
|
return [seqno, packet];
|
|
792
1385
|
}
|
|
1386
|
+
/**
|
|
1387
|
+
* Stops the feedback interval timer, ending periodic keepalive requests.
|
|
1388
|
+
*/
|
|
793
1389
|
teardown() {
|
|
794
1390
|
if (!this.#feedbackInterval) return;
|
|
795
1391
|
clearInterval(this.#feedbackInterval);
|
|
796
1392
|
this.#feedbackInterval = void 0;
|
|
797
1393
|
}
|
|
798
1394
|
};
|
|
1395
|
+
/**
|
|
1396
|
+
* Creates a fresh StreamContext with CD-quality audio defaults
|
|
1397
|
+
* (44100 Hz, stereo, 16-bit) and randomized RTP sequence/timestamp values.
|
|
1398
|
+
*
|
|
1399
|
+
* @returns A new mutable stream context ready for use in a streaming session.
|
|
1400
|
+
*/
|
|
799
1401
|
function createStreamContext() {
|
|
1402
|
+
const rtptime = Math.floor(Math.random() * 4294967295);
|
|
800
1403
|
return {
|
|
801
1404
|
sampleRate: SAMPLE_RATE,
|
|
802
1405
|
channels: CHANNELS,
|
|
803
1406
|
bytesPerChannel: BYTES_PER_CHANNEL,
|
|
804
1407
|
rtpseq: Math.floor(Math.random() * 65536),
|
|
805
|
-
rtptime
|
|
806
|
-
headTs:
|
|
1408
|
+
rtptime,
|
|
1409
|
+
headTs: rtptime,
|
|
807
1410
|
latency: Math.floor(SAMPLE_RATE * 2),
|
|
808
1411
|
serverPort: 0,
|
|
809
1412
|
controlPort: 0,
|