@corewire/airplay 0.0.0 → 1.0.0-next.1
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 +30 -1
- package/dist/chunk-w6R9maHv.mjs +18 -0
- package/dist/describe-CAEDGJLu.mjs +12457 -0
- package/dist/identifiers-CsXXbYkW.mjs +67 -0
- package/dist/index-BlcOBfSz.d.mts +87 -0
- package/dist/index-YqIv0vCR.d.mts +135 -0
- package/dist/index.d.mts +105 -0
- package/dist/index.mjs +3637 -0
- package/dist/mrp.d.mts +687 -0
- package/dist/mrp.mjs +2102 -0
- package/dist/raop.d.mts +344 -0
- package/dist/raop.mjs +1248 -0
- package/dist/rtsp-BWFaYW5H.mjs +374 -0
- package/dist/rtsp.d.mts +2 -0
- package/dist/rtsp.mjs +3 -0
- package/dist/session-oDF9A9as.d.mts +13915 -0
- package/package.json +72 -18
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,3637 @@
|
|
|
1
|
+
import { i as AudioSourceSession, n as generateDacpId, r as generateSessionId, t as generateActiveRemoteId } from "./identifiers-CsXXbYkW.mjs";
|
|
2
|
+
import { a as AUDIO_BYTES_PER_CHANNEL, c as DEFAULT_PORT, l as FEEDBACK_INTERVAL, n as buildResponse, o as AUDIO_CHANNELS, r as parseRequest, s as AUDIO_SAMPLE_RATE, t as RtspClient, u as HTTP_TIMEOUT } from "./rtsp-BWFaYW5H.mjs";
|
|
3
|
+
import { Bt as resolveIdentity, Ft as ProtocolMessage_Type, Gt as randomInt32, Ht as buildReply, It as getExtension, Kt as randomInt64, Pt as ProtocolMessageSchema, Rt as toBinary, Ut as getMacAddress, Vt as buildHeader$1, Wt as parseHeaderSeqno, a as parseMessages, i as messageKind, r as encodeVarint, t as describeProtocolMessage, w as SetConnectionStateMessage_ConnectionState } from "./describe-CAEDGJLu.mjs";
|
|
4
|
+
import { randomBytes, randomUUID } from "node:crypto";
|
|
5
|
+
import { EventEmitter } from "node:events";
|
|
6
|
+
import { ConnectionClosedError, EncryptionError, Logger, PairingError, PlaybackError, SetupError, TimeoutError, reporter, waitFor } from "@corewire/core";
|
|
7
|
+
import { NTP, Plist } from "@corewire/core/encoding";
|
|
8
|
+
import { Context, EncryptionAwareConnection, EncryptionState, buildNonce } from "@corewire/core/transport";
|
|
9
|
+
import { createSocket } from "node:dgram";
|
|
10
|
+
import { ChaCha20, HKDF } from "@corewire/core/crypto";
|
|
11
|
+
import { PairSetup, PairVerify, deriveControlKeys } from "@corewire/core/pairing";
|
|
12
|
+
import { Discovery } from "@corewire/core/discovery";
|
|
13
|
+
|
|
14
|
+
//#region src/features.ts
|
|
15
|
+
/**
|
|
16
|
+
* Parses the `features` TXT value of an mDNS record: one hex value, or two comma-separated 32-bit
|
|
17
|
+
* hex values (low,high) that form a 64-bit mask.
|
|
18
|
+
*
|
|
19
|
+
* @throws If the value has neither form.
|
|
20
|
+
*/
|
|
21
|
+
function parseFeatures(features) {
|
|
22
|
+
const parts = features.split(",").map((part) => part.trim());
|
|
23
|
+
if (parts.length === 1) return BigInt(parts[0]);
|
|
24
|
+
if (parts.length === 2) {
|
|
25
|
+
const low = BigInt(parts[0]);
|
|
26
|
+
return BigInt(parts[1]) << 32n | low;
|
|
27
|
+
}
|
|
28
|
+
throw new Error(`Invalid features format: ${features}`);
|
|
29
|
+
}
|
|
30
|
+
/** Decodes Apple's full, little-endian feature vector from /info. */
|
|
31
|
+
function parseReceiverFeatures(info) {
|
|
32
|
+
if (typeof info.featuresEx === "string") {
|
|
33
|
+
const value = info.featuresEx;
|
|
34
|
+
if (!/^[A-Za-z0-9+/]*={0,2}$/.test(value) || value.length % 4 === 1) throw new TypeError("Invalid base64 featuresEx");
|
|
35
|
+
const bytes = Buffer.from(value, "base64");
|
|
36
|
+
if (bytes.toString("base64").replace(/=+$/, "") !== value.replace(/=+$/, "")) throw new TypeError("Invalid base64 featuresEx");
|
|
37
|
+
let features = 0n;
|
|
38
|
+
for (let i = bytes.length - 1; i >= 0; i--) features = features << 8n | BigInt(bytes[i]);
|
|
39
|
+
return features;
|
|
40
|
+
}
|
|
41
|
+
const legacy = parseLegacyFeatures(info.features ?? 0n);
|
|
42
|
+
return info.featuresEx == null ? legacy : legacy | parseLegacyFeatures(info.featuresEx) << 32n;
|
|
43
|
+
}
|
|
44
|
+
/** Encodes the legacy mask and Apple's base64 extended representation for SETUP. */
|
|
45
|
+
function encodeFeatures(features) {
|
|
46
|
+
if (features < 0n) throw new RangeError("Features must be non-negative");
|
|
47
|
+
const bytes = [];
|
|
48
|
+
for (let remaining = features; remaining > 0n; remaining >>= 8n) bytes.push(Number(remaining & 255n));
|
|
49
|
+
return {
|
|
50
|
+
features: features & 18446744073709551615n,
|
|
51
|
+
featuresEx: Buffer.from(bytes).toString("base64")
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
function parseLegacyFeatures(value) {
|
|
55
|
+
if (typeof value === "bigint" && value >= 0n) return value;
|
|
56
|
+
if (typeof value === "number" && Number.isSafeInteger(value) && value >= 0) return BigInt(value);
|
|
57
|
+
if (typeof value === "string") {
|
|
58
|
+
if (/^(?:0x[0-9a-f]+|[0-9]+)$/i.test(value)) return BigInt(value);
|
|
59
|
+
if (/^[0-9a-f]+$/i.test(value)) return BigInt(`0x${value}`);
|
|
60
|
+
}
|
|
61
|
+
throw new TypeError("Invalid or imprecise legacy features");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
//#endregion
|
|
65
|
+
//#region src/airplayFeatures.ts
|
|
66
|
+
/**
|
|
67
|
+
* AirPlay feature flags as bigint bits of the `features` field in the mDNS TXT
|
|
68
|
+
* record. Each flag is a capability the device advertises.
|
|
69
|
+
*/
|
|
70
|
+
const AirPlayFeatureFlags = {
|
|
71
|
+
SupportsAirPlayVideoV1: 1n << 0n,
|
|
72
|
+
SupportsAirPlayPhoto: 1n << 1n,
|
|
73
|
+
SupportsAirPlayVideoFairPlay: 1n << 2n,
|
|
74
|
+
SupportsAirPlayVideoVolumeControl: 1n << 3n,
|
|
75
|
+
SupportsAirPlayVideoHTTPLiveStreams: 1n << 4n,
|
|
76
|
+
SupportsAirPlaySlideShow: 1n << 5n,
|
|
77
|
+
SupportsAirPlayScreen: 1n << 7n,
|
|
78
|
+
SupportsAirPlayAudio: 1n << 9n,
|
|
79
|
+
AudioRedundant: 1n << 11n,
|
|
80
|
+
Authentication4: 1n << 14n,
|
|
81
|
+
MetadataFeatures0: 1n << 15n,
|
|
82
|
+
MetadataFeatures1: 1n << 16n,
|
|
83
|
+
MetadataFeatures2: 1n << 17n,
|
|
84
|
+
AudioFormats0: 1n << 18n,
|
|
85
|
+
AudioFormats1: 1n << 19n,
|
|
86
|
+
AudioFormats2: 1n << 20n,
|
|
87
|
+
AudioFormats3: 1n << 21n,
|
|
88
|
+
Authentication1: 1n << 23n,
|
|
89
|
+
Authentication8: 1n << 26n,
|
|
90
|
+
SupportsLegacyPairing: 1n << 27n,
|
|
91
|
+
HasUnifiedAdvertiserInfo: 1n << 30n,
|
|
92
|
+
SupportsVolume: 1n << 32n,
|
|
93
|
+
SupportsAirPlayVideoPlayQueue: 1n << 33n,
|
|
94
|
+
SupportsAirPlayFromCloud: 1n << 34n,
|
|
95
|
+
SupportsTLSPSK: 1n << 35n,
|
|
96
|
+
SupportsUnifiedMediaControl: 1n << 38n,
|
|
97
|
+
SupportsBufferedAudio: 1n << 40n,
|
|
98
|
+
SupportsPTP: 1n << 41n,
|
|
99
|
+
SupportsScreenMultiCodec: 1n << 42n,
|
|
100
|
+
SupportsSystemPairing: 1n << 43n,
|
|
101
|
+
IsAPValeriaScreenSender: 1n << 44n,
|
|
102
|
+
SupportsHKPairingAndAccessControl: 1n << 46n,
|
|
103
|
+
SupportsCoreUtilsPairingAndEncryption: 1n << 48n,
|
|
104
|
+
SupportsAirPlayVideoV2: 1n << 49n,
|
|
105
|
+
MetadataFeatures3: 1n << 50n,
|
|
106
|
+
SupportsUnifiedPairSetupAndMFi: 1n << 51n,
|
|
107
|
+
SupportsSetPeersExtendedMessage: 1n << 52n,
|
|
108
|
+
SupportsAPSync: 1n << 54n,
|
|
109
|
+
SupportsWoL: 1n << 55n,
|
|
110
|
+
SupportsWoL2: 1n << 56n,
|
|
111
|
+
SupportsHangdogRemoteControl: 1n << 58n,
|
|
112
|
+
SupportsAudioStreamConnectionSetup: 1n << 59n,
|
|
113
|
+
SupportsAudioMetadataControl: 1n << 60n,
|
|
114
|
+
SupportsRFC2198Redundancy: 1n << 61n
|
|
115
|
+
};
|
|
116
|
+
/** Password protection bit of the `sf` (status flags) TXT field. */
|
|
117
|
+
const PASSWORD_BIT = 128n;
|
|
118
|
+
/** Legacy pairing bit of the `sf` TXT field; it requires a PIN. */
|
|
119
|
+
const LEGACY_PAIRING_BIT = 512n;
|
|
120
|
+
/** PIN required bit of the `sf` TXT field. */
|
|
121
|
+
const PIN_REQUIRED_BIT = 8n;
|
|
122
|
+
function hasFeatureFlag(features, flag) {
|
|
123
|
+
return (features & flag) === flag;
|
|
124
|
+
}
|
|
125
|
+
/** Names of the flags set in the bitmask, for debugging and diagnostics. */
|
|
126
|
+
function describeFlags(features) {
|
|
127
|
+
const result = [];
|
|
128
|
+
for (const [name, flag] of Object.entries(AirPlayFeatureFlags)) if (hasFeatureFlag(features, flag)) result.push(name);
|
|
129
|
+
return result;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Reads the AirPlay version from the mDNS TXT record. The flag
|
|
133
|
+
* SupportsUnifiedMediaControl or SupportsCoreUtilsPairingAndEncryption means
|
|
134
|
+
* AirPlay 2.
|
|
135
|
+
*/
|
|
136
|
+
function getProtocolVersion(txt) {
|
|
137
|
+
const featuresStr = txt.features ?? txt.ft;
|
|
138
|
+
if (!featuresStr) return 1;
|
|
139
|
+
const features = parseFeatures(featuresStr);
|
|
140
|
+
if (hasFeatureFlag(features, AirPlayFeatureFlags.SupportsUnifiedMediaControl)) return 2;
|
|
141
|
+
if (hasFeatureFlag(features, AirPlayFeatureFlags.SupportsCoreUtilsPairingAndEncryption)) return 2;
|
|
142
|
+
return 1;
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Reads the pairing requirement from the feature and status flags of the
|
|
146
|
+
* mDNS TXT record. The order of precedence is HomeKit, PIN required,
|
|
147
|
+
* transient (system) pairing, legacy PIN, none.
|
|
148
|
+
*/
|
|
149
|
+
function getPairingRequirement(txt) {
|
|
150
|
+
const featuresStr = txt.features ?? txt.ft;
|
|
151
|
+
if (!featuresStr) return "none";
|
|
152
|
+
const features = parseFeatures(featuresStr);
|
|
153
|
+
const sf = parseStatusFlags(txt);
|
|
154
|
+
if (hasFeatureFlag(features, AirPlayFeatureFlags.SupportsHKPairingAndAccessControl)) return "homekit";
|
|
155
|
+
if ((sf & PIN_REQUIRED_BIT) !== 0n) return "pin";
|
|
156
|
+
if (hasFeatureFlag(features, AirPlayFeatureFlags.SupportsSystemPairing)) return "transient";
|
|
157
|
+
if ((sf & LEGACY_PAIRING_BIT) !== 0n) return "pin";
|
|
158
|
+
return "none";
|
|
159
|
+
}
|
|
160
|
+
/** True when the `pw` TXT field or the password bit of the `sf` status flags is set. */
|
|
161
|
+
function isPasswordRequired(txt) {
|
|
162
|
+
if (txt.pw === "true") return true;
|
|
163
|
+
return (parseStatusFlags(txt) & PASSWORD_BIT) !== 0n;
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* True when the device has the SupportsHangdogRemoteControl flag, which it
|
|
167
|
+
* needs to receive HID events. In practice only an Apple TV has it.
|
|
168
|
+
*/
|
|
169
|
+
function isRemoteControlSupported(txt) {
|
|
170
|
+
const featuresStr = txt.features ?? txt.ft;
|
|
171
|
+
if (!featuresStr) return false;
|
|
172
|
+
return hasFeatureFlag(parseFeatures(featuresStr), AirPlayFeatureFlags.SupportsHangdogRemoteControl);
|
|
173
|
+
}
|
|
174
|
+
/** Feature bitmask we advertise for a remote control session. */
|
|
175
|
+
const SENDER_FEATURES_REMOTE_CONTROL = AirPlayFeatureFlags.SupportsAirPlayAudio | AirPlayFeatureFlags.AudioRedundant | AirPlayFeatureFlags.MetadataFeatures0 | AirPlayFeatureFlags.MetadataFeatures1 | AirPlayFeatureFlags.MetadataFeatures2 | AirPlayFeatureFlags.MetadataFeatures3 | AirPlayFeatureFlags.Authentication4 | AirPlayFeatureFlags.Authentication1 | AirPlayFeatureFlags.HasUnifiedAdvertiserInfo | AirPlayFeatureFlags.SupportsUnifiedMediaControl | AirPlayFeatureFlags.SupportsSystemPairing | AirPlayFeatureFlags.SupportsCoreUtilsPairingAndEncryption | AirPlayFeatureFlags.SupportsHKPairingAndAccessControl | AirPlayFeatureFlags.SupportsHangdogRemoteControl | AirPlayFeatureFlags.SupportsAPSync | AirPlayFeatureFlags.SupportsSetPeersExtendedMessage | AirPlayFeatureFlags.SupportsVolume;
|
|
176
|
+
/** Feature bitmask we advertise for an audio streaming session: the remote control features plus the audio ones. */
|
|
177
|
+
const SENDER_FEATURES_AUDIO = SENDER_FEATURES_REMOTE_CONTROL | AirPlayFeatureFlags.SupportsBufferedAudio | AirPlayFeatureFlags.SupportsAudioStreamConnectionSetup | AirPlayFeatureFlags.SupportsAudioMetadataControl | AirPlayFeatureFlags.AudioFormats0 | AirPlayFeatureFlags.AudioFormats1 | AirPlayFeatureFlags.AudioFormats2 | AirPlayFeatureFlags.AudioFormats3 | AirPlayFeatureFlags.SupportsPTP;
|
|
178
|
+
function parseStatusFlags(txt) {
|
|
179
|
+
const value = (txt.sf || txt.flags || "0").trim().replace(/^0x/i, "");
|
|
180
|
+
if (!/^[0-9a-f]+$/i.test(value)) return 0n;
|
|
181
|
+
return BigInt(`0x${value}`);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
//#endregion
|
|
185
|
+
//#region src/audio/playback.ts
|
|
186
|
+
/**
|
|
187
|
+
* A running audio stream on a connection of its own. Owns that connection and the source: both
|
|
188
|
+
* end when the source runs out, on {@link stop}, or when the session it came from ends.
|
|
189
|
+
*/
|
|
190
|
+
var AudioPlayback = class {
|
|
191
|
+
/**
|
|
192
|
+
* Settles when streaming is over: resolves once the source ran out and TEARDOWN went out,
|
|
193
|
+
* rejects when the stream failed or was stopped halfway.
|
|
194
|
+
*/
|
|
195
|
+
get ended() {
|
|
196
|
+
return this.#ended;
|
|
197
|
+
}
|
|
198
|
+
/** Aborts when the stream's connection closes. */
|
|
199
|
+
get signal() {
|
|
200
|
+
return this.#session.signal;
|
|
201
|
+
}
|
|
202
|
+
get stats() {
|
|
203
|
+
return this.#stream.stats;
|
|
204
|
+
}
|
|
205
|
+
#ended;
|
|
206
|
+
#session;
|
|
207
|
+
#source;
|
|
208
|
+
#stream;
|
|
209
|
+
constructor(session, stream, source) {
|
|
210
|
+
this.#session = session;
|
|
211
|
+
this.#source = source;
|
|
212
|
+
this.#stream = stream;
|
|
213
|
+
this.#ended = this.#run();
|
|
214
|
+
this.#ended.catch(() => void 0);
|
|
215
|
+
}
|
|
216
|
+
/** Stops the source and closes the connection, without TEARDOWN. */
|
|
217
|
+
stop() {
|
|
218
|
+
this.#source.stop().catch((error) => this.#session.context.logger.error("Audio source cleanup failed", error));
|
|
219
|
+
this.#session.close();
|
|
220
|
+
}
|
|
221
|
+
async #run() {
|
|
222
|
+
try {
|
|
223
|
+
await this.#stream.stream(this.#source, this.#session.target.address);
|
|
224
|
+
} finally {
|
|
225
|
+
this.#stream.close();
|
|
226
|
+
this.#session.close();
|
|
227
|
+
await this.#source.stop();
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
//#endregion
|
|
233
|
+
//#region src/audio/latency.ts
|
|
234
|
+
/** Latency tiers in ascending order, in seconds (fractions of the sample rate). */
|
|
235
|
+
const LATENCY_TIERS = [
|
|
236
|
+
.25,
|
|
237
|
+
.5,
|
|
238
|
+
1,
|
|
239
|
+
2
|
|
240
|
+
];
|
|
241
|
+
/** Number of consecutive successful packets needed to drop one latency tier. */
|
|
242
|
+
const STABILITY_THRESHOLD = 500;
|
|
243
|
+
/** Number of glitches within the glitch window that trigger a tier increase. */
|
|
244
|
+
const GLITCH_THRESHOLD = 3;
|
|
245
|
+
/** Time window in milliseconds for counting glitches. */
|
|
246
|
+
const GLITCH_WINDOW_MS = 5e3;
|
|
247
|
+
/**
|
|
248
|
+
* Picks the latency tier of an audio stream. Glitches (packet loss, NACK,
|
|
249
|
+
* late arrival) reported through {@link reportGlitch} raise the tier; a run
|
|
250
|
+
* of {@link reportSuccess} calls lowers it.
|
|
251
|
+
*/
|
|
252
|
+
var LatencyManager = class {
|
|
253
|
+
#sampleRate;
|
|
254
|
+
#tierIndex = 0;
|
|
255
|
+
#consecutiveSuccesses = 0;
|
|
256
|
+
#glitchTimestamps = [];
|
|
257
|
+
/**
|
|
258
|
+
* @param sampleRate - Sample rate in Hz.
|
|
259
|
+
* @param initialTierIndex - Starting tier, where 0 is the lowest latency.
|
|
260
|
+
*/
|
|
261
|
+
constructor(sampleRate, initialTierIndex = 0) {
|
|
262
|
+
this.#sampleRate = sampleRate;
|
|
263
|
+
this.#tierIndex = Math.min(Math.max(0, initialTierIndex), LATENCY_TIERS.length - 1);
|
|
264
|
+
}
|
|
265
|
+
/** Index of the current latency tier, from 0 (lowest latency) to 3 (highest). */
|
|
266
|
+
get tierIndex() {
|
|
267
|
+
return this.#tierIndex;
|
|
268
|
+
}
|
|
269
|
+
/** Current latency in samples. */
|
|
270
|
+
get latency() {
|
|
271
|
+
return Math.round(this.#sampleRate * LATENCY_TIERS[this.#tierIndex]);
|
|
272
|
+
}
|
|
273
|
+
/** Current latency in milliseconds. */
|
|
274
|
+
get latencyMs() {
|
|
275
|
+
return Math.round(LATENCY_TIERS[this.#tierIndex] * 1e3);
|
|
276
|
+
}
|
|
277
|
+
get isMaxTier() {
|
|
278
|
+
return this.#tierIndex >= LATENCY_TIERS.length - 1;
|
|
279
|
+
}
|
|
280
|
+
get isMinTier() {
|
|
281
|
+
return this.#tierIndex <= 0;
|
|
282
|
+
}
|
|
283
|
+
/** Current latency in samples, same as {@link latency}. */
|
|
284
|
+
getLatency() {
|
|
285
|
+
return this.latency;
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* Reports a packet that was sent and acknowledged. Enough consecutive
|
|
289
|
+
* successes lower the tier.
|
|
290
|
+
*/
|
|
291
|
+
reportSuccess() {
|
|
292
|
+
this.#consecutiveSuccesses++;
|
|
293
|
+
if (this.#consecutiveSuccesses >= STABILITY_THRESHOLD && !this.isMinTier) {
|
|
294
|
+
this.#tierIndex--;
|
|
295
|
+
this.#consecutiveSuccesses = 0;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Reports a glitch: packet loss, a retransmission request or a late
|
|
300
|
+
* arrival. Enough glitches within the window raise the tier.
|
|
301
|
+
*/
|
|
302
|
+
reportGlitch() {
|
|
303
|
+
this.#consecutiveSuccesses = 0;
|
|
304
|
+
const now = performance.now();
|
|
305
|
+
this.#glitchTimestamps.push(now);
|
|
306
|
+
const windowStart = now - GLITCH_WINDOW_MS;
|
|
307
|
+
this.#glitchTimestamps = this.#glitchTimestamps.filter((timestamp) => timestamp >= windowStart);
|
|
308
|
+
if (this.#glitchTimestamps.length >= GLITCH_THRESHOLD && !this.isMaxTier) {
|
|
309
|
+
this.#tierIndex++;
|
|
310
|
+
this.#glitchTimestamps = [];
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
/** RFC 2198 redundancy level for the current tier; a higher tier gets more. */
|
|
314
|
+
get recommendedRedundancy() {
|
|
315
|
+
if (this.#tierIndex >= 2) return 3;
|
|
316
|
+
if (this.#tierIndex >= 1) return 2;
|
|
317
|
+
return 1;
|
|
318
|
+
}
|
|
319
|
+
/** Returns to the lowest tier. */
|
|
320
|
+
reset() {
|
|
321
|
+
this.#tierIndex = 0;
|
|
322
|
+
this.#consecutiveSuccesses = 0;
|
|
323
|
+
this.#glitchTimestamps = [];
|
|
324
|
+
}
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
//#endregion
|
|
328
|
+
//#region src/audio/pacing.ts
|
|
329
|
+
/** Maximum number of extra packets per cycle when catching up. */
|
|
330
|
+
const MAX_PACKETS_COMPENSATE = 3;
|
|
331
|
+
/** Number of consecutive slow packets before logging a warning. */
|
|
332
|
+
const SLOW_WARNING_THRESHOLD = 5;
|
|
333
|
+
/**
|
|
334
|
+
* Paces audio packets against wall-clock time, sending up to {@link MAX_PACKETS_COMPENSATE} extra packets per cycle when behind.
|
|
335
|
+
*
|
|
336
|
+
* @param sendPacket - Returns frames sent, or 0 when done.
|
|
337
|
+
* @returns Total packets sent.
|
|
338
|
+
*/
|
|
339
|
+
async function streamWithTiming(sendPacket, options) {
|
|
340
|
+
const { sampleRate, logger, logPrefix } = options;
|
|
341
|
+
let firstPacket = true;
|
|
342
|
+
let packetCount = 0;
|
|
343
|
+
let slowCount = 0;
|
|
344
|
+
let totalFrames = 0;
|
|
345
|
+
const startTime = performance.now();
|
|
346
|
+
while (true) {
|
|
347
|
+
const framesSent = await sendPacket(firstPacket);
|
|
348
|
+
if (framesSent === 0) {
|
|
349
|
+
logger.debug(logPrefix, `End of stream after ${packetCount} packets`);
|
|
350
|
+
break;
|
|
351
|
+
}
|
|
352
|
+
totalFrames += framesSent;
|
|
353
|
+
packetCount++;
|
|
354
|
+
firstPacket = false;
|
|
355
|
+
if (packetCount % 100 === 0) logger.debug(logPrefix, `Sent ${packetCount} packets, ${totalFrames} frames`);
|
|
356
|
+
const sleepTime = totalFrames / sampleRate * 1e3 - (performance.now() - startTime);
|
|
357
|
+
if (sleepTime > 0) {
|
|
358
|
+
slowCount = 0;
|
|
359
|
+
await waitFor(sleepTime);
|
|
360
|
+
} else {
|
|
361
|
+
const framesBehind = Math.floor(-sleepTime / 1e3 * sampleRate);
|
|
362
|
+
if (framesBehind >= 352) {
|
|
363
|
+
const extraPackets = Math.min(Math.floor(framesBehind / 352), MAX_PACKETS_COMPENSATE);
|
|
364
|
+
for (let idx = 0; idx < extraPackets; idx++) {
|
|
365
|
+
const extra = await sendPacket(false);
|
|
366
|
+
if (extra === 0) break;
|
|
367
|
+
totalFrames += extra;
|
|
368
|
+
packetCount++;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
slowCount++;
|
|
372
|
+
if (slowCount >= SLOW_WARNING_THRESHOLD) {
|
|
373
|
+
logger.warn(logPrefix, `Stream behind schedule (${slowCount} consecutive, ${Math.abs(sleepTime).toFixed(1)}ms behind)`);
|
|
374
|
+
slowCount = 0;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
return packetCount;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
//#endregion
|
|
382
|
+
//#region src/audio/stream.ts
|
|
383
|
+
/** Maximum number of packets to keep in the retransmission backlog. */
|
|
384
|
+
const PACKET_BACKLOG_SIZE = 1e3;
|
|
385
|
+
/** Interval in milliseconds between RTCP sync packets. */
|
|
386
|
+
const SYNC_INTERVAL = 1e3;
|
|
387
|
+
/** Default number of previous frames sent as RFC 2198 redundancy; 0 disables it. */
|
|
388
|
+
const DEFAULT_REDUNDANCY_COUNT = 0;
|
|
389
|
+
/** Compression types for SETUP `ct`. */
|
|
390
|
+
const CompressionType = {
|
|
391
|
+
PCM: 1,
|
|
392
|
+
ALAC: 2,
|
|
393
|
+
AAC_LC: 4,
|
|
394
|
+
AAC_ELD: 8,
|
|
395
|
+
Opus: 32
|
|
396
|
+
};
|
|
397
|
+
/** SETUP `audioFormat` bits, named `{codec}_{sampleRate}_{bitDepth}_{channels}`. */
|
|
398
|
+
const AudioFormat = {
|
|
399
|
+
PCM_8000_16_1: 4,
|
|
400
|
+
PCM_8000_16_2: 8,
|
|
401
|
+
PCM_16000_16_1: 16,
|
|
402
|
+
PCM_16000_16_2: 32,
|
|
403
|
+
PCM_24000_16_1: 64,
|
|
404
|
+
PCM_24000_16_2: 128,
|
|
405
|
+
PCM_32000_16_1: 256,
|
|
406
|
+
PCM_32000_16_2: 512,
|
|
407
|
+
PCM_44100_16_1: 1024,
|
|
408
|
+
PCM_44100_16_2: 2048,
|
|
409
|
+
PCM_44100_24_1: 4096,
|
|
410
|
+
PCM_44100_24_2: 8192,
|
|
411
|
+
PCM_48000_16_1: 16384,
|
|
412
|
+
PCM_48000_16_2: 32768,
|
|
413
|
+
PCM_48000_24_1: 65536,
|
|
414
|
+
PCM_48000_24_2: 131072,
|
|
415
|
+
ALAC_44100_16_2: 262144,
|
|
416
|
+
ALAC_44100_24_2: 524288,
|
|
417
|
+
ALAC_48000_16_2: 1048576,
|
|
418
|
+
ALAC_48000_24_2: 2097152,
|
|
419
|
+
AAC_LC_44100_2: 4194304,
|
|
420
|
+
AAC_LC_48000_2: 8388608,
|
|
421
|
+
AAC_ELD_44100_2: 16777216,
|
|
422
|
+
AAC_ELD_48000_2: 33554432,
|
|
423
|
+
AAC_ELD_16000_1: 67108864,
|
|
424
|
+
AAC_ELD_24000_1: 134217728,
|
|
425
|
+
AAC_ELD_48000_1: 4294967296
|
|
426
|
+
};
|
|
427
|
+
/** Converts an RTP timestamp to NTP against the wall-clock anchor taken at stream start, for multiroom sync. */
|
|
428
|
+
function rtpToNtp(rtpTimestamp, sampleRate, anchorRtp, anchorNtp) {
|
|
429
|
+
let elapsedSamples;
|
|
430
|
+
if (rtpTimestamp >= anchorRtp) elapsedSamples = rtpTimestamp - anchorRtp;
|
|
431
|
+
else elapsedSamples = 4294967296 - anchorRtp + rtpTimestamp;
|
|
432
|
+
const elapsedSeconds = Math.floor(elapsedSamples / sampleRate);
|
|
433
|
+
const elapsedFraction = elapsedSamples % sampleRate * 4294967295 / sampleRate;
|
|
434
|
+
return anchorNtp + (BigInt(elapsedSeconds) << 32n | BigInt(Math.floor(elapsedFraction)));
|
|
435
|
+
}
|
|
436
|
+
/**
|
|
437
|
+
* Encrypted RTP audio over UDP with RTCP synchronization, NACK retransmission and optional RFC 2198 redundancy.
|
|
438
|
+
* Call {@link setup}, then {@link prepare}, send frames and call {@link finish}, or use {@link stream} after setup.
|
|
439
|
+
*/
|
|
440
|
+
var AudioStream = class {
|
|
441
|
+
#address;
|
|
442
|
+
#context;
|
|
443
|
+
#control;
|
|
444
|
+
#supportedFormats;
|
|
445
|
+
/** RFC 2198 redundancy count; 0 disables it. */
|
|
446
|
+
#redundancyCount;
|
|
447
|
+
#controlPort = 0;
|
|
448
|
+
#source;
|
|
449
|
+
#closed = false;
|
|
450
|
+
/** UDP socket for RTCP control messages (sync, retransmit requests). */
|
|
451
|
+
#controlSocket;
|
|
452
|
+
/** 32-byte shared key for ChaCha20 audio encryption. */
|
|
453
|
+
#sharedKey;
|
|
454
|
+
/** Remote data port from the SETUP response. */
|
|
455
|
+
#dataPort = 0;
|
|
456
|
+
#dataSocket;
|
|
457
|
+
#negotiatedBytesPerChannel = 2;
|
|
458
|
+
#negotiatedSampleRate = AUDIO_SAMPLE_RATE;
|
|
459
|
+
/** RTP timestamp of the NTP anchor. */
|
|
460
|
+
#anchorRtp = 0;
|
|
461
|
+
/** NTP timestamp of the anchor, for RTP to NTP conversion. */
|
|
462
|
+
#anchorNtp = 0n;
|
|
463
|
+
/** Previous frames kept for RFC 2198 redundancy. */
|
|
464
|
+
#previousFrames = [];
|
|
465
|
+
/** Remote RTCP control port from the SETUP response. */
|
|
466
|
+
#remoteControlPort = 0;
|
|
467
|
+
/** Sent packets kept for retransmission on NACK, keyed by RTP sequence number. */
|
|
468
|
+
#packetBacklog = /* @__PURE__ */ new Map();
|
|
469
|
+
/** Random RTP synchronization source identifier. */
|
|
470
|
+
#ssrc = 0;
|
|
471
|
+
#syncInterval;
|
|
472
|
+
#streamContext;
|
|
473
|
+
#latencyManager;
|
|
474
|
+
/** ChaCha20 nonce counter, independent of the RTP sequence number. */
|
|
475
|
+
#encryptionCounter = 0;
|
|
476
|
+
#packetsSent = 0;
|
|
477
|
+
#retransmitRequests = 0;
|
|
478
|
+
#retransmitsFulfilled = 0;
|
|
479
|
+
#retransmitsFailed = 0;
|
|
480
|
+
#totalBytesSent = 0;
|
|
481
|
+
get stats() {
|
|
482
|
+
return {
|
|
483
|
+
packetsSent: this.#packetsSent,
|
|
484
|
+
retransmitRequests: this.#retransmitRequests,
|
|
485
|
+
retransmitsFulfilled: this.#retransmitsFulfilled,
|
|
486
|
+
retransmitsFailed: this.#retransmitsFailed,
|
|
487
|
+
packetLossRate: this.#packetsSent > 0 ? this.#retransmitRequests / this.#packetsSent : 0,
|
|
488
|
+
totalBytesSent: this.#totalBytesSent
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
constructor(target, options) {
|
|
492
|
+
this.#address = target.address;
|
|
493
|
+
this.#context = target.context;
|
|
494
|
+
this.#control = target.control;
|
|
495
|
+
this.#supportedFormats = target.supportedAudioFormats;
|
|
496
|
+
this.#redundancyCount = options?.redundancyCount ?? DEFAULT_REDUNDANCY_COUNT;
|
|
497
|
+
}
|
|
498
|
+
/**
|
|
499
|
+
* Negotiates PCM 44100/16/stereo and UDP ports via SETUP, then sends RECORD.
|
|
500
|
+
*
|
|
501
|
+
* @returns The assigned data and control ports.
|
|
502
|
+
* @throws SetupError if SETUP fails or returns no stream info.
|
|
503
|
+
*/
|
|
504
|
+
async setup() {
|
|
505
|
+
if (this.#closed) throw new SetupError("Audio setup cancelled.");
|
|
506
|
+
this.#sharedKey = Buffer.from(randomBytes(32));
|
|
507
|
+
this.#ssrc = randomInt32() >>> 0;
|
|
508
|
+
const socket = this.#controlSocket = createSocket("udp4");
|
|
509
|
+
this.#controlSocket.on("message", (data, rinfo) => this.#onControlMessage(data, rinfo));
|
|
510
|
+
await new Promise((resolve, reject) => {
|
|
511
|
+
const cleanup = () => {
|
|
512
|
+
socket.off("error", fail);
|
|
513
|
+
socket.off("close", closed);
|
|
514
|
+
};
|
|
515
|
+
const fail = (error) => {
|
|
516
|
+
cleanup();
|
|
517
|
+
reject(error);
|
|
518
|
+
};
|
|
519
|
+
const closed = () => fail(new SetupError("Audio setup cancelled."));
|
|
520
|
+
socket.once("error", fail);
|
|
521
|
+
socket.once("close", closed);
|
|
522
|
+
socket.bind(0, () => {
|
|
523
|
+
cleanup();
|
|
524
|
+
if (this.#closed) {
|
|
525
|
+
reject(new SetupError("Audio setup cancelled."));
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
this.#controlPort = socket.address().port;
|
|
529
|
+
resolve();
|
|
530
|
+
});
|
|
531
|
+
});
|
|
532
|
+
const shkArrayBuffer = this.#sharedKey.buffer.slice(this.#sharedKey.byteOffset, this.#sharedKey.byteOffset + this.#sharedKey.byteLength);
|
|
533
|
+
const streamConnectionID = randomInt64();
|
|
534
|
+
const supportedFormats = this.#supportedFormats;
|
|
535
|
+
const ct = CompressionType.PCM;
|
|
536
|
+
const audioFormat = AudioFormat.PCM_44100_16_2;
|
|
537
|
+
const sampleRate = AUDIO_SAMPLE_RATE;
|
|
538
|
+
if (supportedFormats) this.#context.logger.info("[audio]", `Receiver supported formats: 0x${supportedFormats.toString(16)}`);
|
|
539
|
+
const bytesPerChannel = 2;
|
|
540
|
+
const setupBody = Plist.serialize({ streams: [{
|
|
541
|
+
audioFormat,
|
|
542
|
+
audioMode: "default",
|
|
543
|
+
controlPort: this.#controlPort,
|
|
544
|
+
ct,
|
|
545
|
+
isMedia: true,
|
|
546
|
+
latencyMax: sampleRate * 2,
|
|
547
|
+
latencyMin: Math.round(sampleRate * .25),
|
|
548
|
+
shk: shkArrayBuffer,
|
|
549
|
+
spf: 352,
|
|
550
|
+
sr: sampleRate,
|
|
551
|
+
streamConnectionID,
|
|
552
|
+
supportsDynamicStreamID: false,
|
|
553
|
+
redundantAudio: this.#redundancyCount > 0,
|
|
554
|
+
supportsRTPPacketRedundancy: this.#redundancyCount > 0,
|
|
555
|
+
type: 96
|
|
556
|
+
}] });
|
|
557
|
+
this.#context.logger.debug("[audio]", "Sending audio stream SETUP...");
|
|
558
|
+
this.#context.logger.debug("[audio]", "shk length:", this.#sharedKey.length);
|
|
559
|
+
const response = await this.#control.setup(`/${this.#control.sessionId}`, Buffer.from(setupBody), { "Content-Type": "application/x-apple-binary-plist" });
|
|
560
|
+
if (this.#closed) throw new SetupError("Audio setup cancelled.");
|
|
561
|
+
if (response.status !== 200) {
|
|
562
|
+
const text = await response.text();
|
|
563
|
+
throw new SetupError(`Failed to setup audio stream: ${response.status} - ${text}`);
|
|
564
|
+
}
|
|
565
|
+
const plist = Plist.parse(await response.arrayBuffer());
|
|
566
|
+
this.#context.logger.debug("[audio]", "Setup stream response:", plist);
|
|
567
|
+
if (!(plist.streams && plist.streams.length > 0)) throw new SetupError("No stream info in SETUP response.");
|
|
568
|
+
const streamInfo = plist.streams[0];
|
|
569
|
+
this.#dataPort = streamInfo.dataPort & 65535;
|
|
570
|
+
this.#remoteControlPort = streamInfo.controlPort & 65535;
|
|
571
|
+
this.#negotiatedSampleRate = sampleRate;
|
|
572
|
+
this.#negotiatedBytesPerChannel = bytesPerChannel;
|
|
573
|
+
this.#context.logger.info("[audio]", `Audio stream setup: dataPort=${this.#dataPort}, controlPort=${this.#remoteControlPort}, format=0x${audioFormat.toString(16)}, sr=${sampleRate}, bpc=${bytesPerChannel}`);
|
|
574
|
+
this.#context.logger.debug("[audio]", "Sending RECORD...");
|
|
575
|
+
await this.#control.record(`/${this.#control.sessionId}`);
|
|
576
|
+
if (this.#closed) throw new SetupError("Audio setup cancelled.");
|
|
577
|
+
this.#context.logger.debug("[audio]", "RECORD complete");
|
|
578
|
+
return {
|
|
579
|
+
dataPort: this.#dataPort,
|
|
580
|
+
controlPort: this.#remoteControlPort
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
/**
|
|
584
|
+
* Connects the UDP data socket, creates the stream context, sends FLUSH
|
|
585
|
+
* and starts RTCP sync.
|
|
586
|
+
*/
|
|
587
|
+
async prepare(remoteAddress) {
|
|
588
|
+
if (!this.#controlSocket || !this.#sharedKey || !this.#dataPort) throw new SetupError("Audio stream not setup.");
|
|
589
|
+
if (this.#closed) throw new SetupError("Audio preparation cancelled.");
|
|
590
|
+
const socket = this.#dataSocket = createSocket("udp4");
|
|
591
|
+
await new Promise((resolve, reject) => {
|
|
592
|
+
const cleanup = () => {
|
|
593
|
+
socket.off("error", fail);
|
|
594
|
+
socket.off("close", closed);
|
|
595
|
+
};
|
|
596
|
+
const fail = (error) => {
|
|
597
|
+
cleanup();
|
|
598
|
+
reject(error);
|
|
599
|
+
};
|
|
600
|
+
const closed = () => fail(new SetupError("Audio preparation cancelled."));
|
|
601
|
+
socket.once("error", fail);
|
|
602
|
+
socket.once("close", closed);
|
|
603
|
+
socket.connect(this.#dataPort, remoteAddress, () => {
|
|
604
|
+
cleanup();
|
|
605
|
+
this.#closed ? reject(new SetupError("Audio preparation cancelled.")) : resolve();
|
|
606
|
+
});
|
|
607
|
+
});
|
|
608
|
+
const frameSize = 2 * this.#negotiatedBytesPerChannel;
|
|
609
|
+
const packetSize = 352 * frameSize;
|
|
610
|
+
this.#latencyManager = new LatencyManager(this.#negotiatedSampleRate);
|
|
611
|
+
const latency = this.#latencyManager.getLatency();
|
|
612
|
+
const initialRtpTime = 0;
|
|
613
|
+
this.#anchorRtp = initialRtpTime;
|
|
614
|
+
this.#anchorNtp = NTP.now();
|
|
615
|
+
const ctx = {
|
|
616
|
+
sampleRate: this.#negotiatedSampleRate,
|
|
617
|
+
channels: 2,
|
|
618
|
+
bytesPerChannel: this.#negotiatedBytesPerChannel,
|
|
619
|
+
frameSize,
|
|
620
|
+
packetSize,
|
|
621
|
+
rtpSeq: randomInt32() & 65535,
|
|
622
|
+
rtpTime: initialRtpTime,
|
|
623
|
+
headTs: initialRtpTime,
|
|
624
|
+
latency,
|
|
625
|
+
paddingSent: 0,
|
|
626
|
+
totalFrames: 0
|
|
627
|
+
};
|
|
628
|
+
this.#streamContext = ctx;
|
|
629
|
+
this.#context.logger.debug("[audio]", "Sending FLUSH...");
|
|
630
|
+
await this.#control.flush(`/${this.#control.sessionId}`, {
|
|
631
|
+
"Range": "npt=0-",
|
|
632
|
+
"RTP-Info": `seq=${ctx.rtpSeq};rtptime=${ctx.rtpTime}`
|
|
633
|
+
});
|
|
634
|
+
if (this.#closed) throw new SetupError("Audio preparation cancelled.");
|
|
635
|
+
this.#context.logger.debug("[audio]", "FLUSH complete");
|
|
636
|
+
this.#context.logger.debug("[audio]", `RTP start: seq=${ctx.rtpSeq}, time=${ctx.rtpTime}, ssrc=${this.#ssrc}`);
|
|
637
|
+
this.#packetBacklog.clear();
|
|
638
|
+
this.#startSync();
|
|
639
|
+
return ctx;
|
|
640
|
+
}
|
|
641
|
+
/**
|
|
642
|
+
* Sends frames that were already read as one RTP packet. {@link AudioMultiplexer}
|
|
643
|
+
* reads each packet once and sends it to every stream.
|
|
644
|
+
*
|
|
645
|
+
* @param frames - Raw PCM frame data.
|
|
646
|
+
* @param firstPacket - Sets the RTP marker bit.
|
|
647
|
+
* @returns Number of frames sent.
|
|
648
|
+
* @throws SetupError if the stream has not been prepared.
|
|
649
|
+
*/
|
|
650
|
+
async sendFrameData(frames, firstPacket) {
|
|
651
|
+
if (!this.#streamContext) throw new SetupError("Audio stream not prepared.");
|
|
652
|
+
return this.#sendFrameBuffer(frames, firstPacket, this.#streamContext);
|
|
653
|
+
}
|
|
654
|
+
/** Pads with silence for the negotiated latency before TEARDOWN so the receiver can finish buffered audio. */
|
|
655
|
+
async finish() {
|
|
656
|
+
if (!this.#streamContext) return;
|
|
657
|
+
const ctx = this.#streamContext;
|
|
658
|
+
const startFrames = ctx.totalFrames;
|
|
659
|
+
const startTime = performance.now();
|
|
660
|
+
while (ctx.paddingSent < ctx.latency) {
|
|
661
|
+
const silence = Buffer.alloc(ctx.packetSize, 0);
|
|
662
|
+
const sent = await this.#sendFrameBuffer(silence, false, ctx);
|
|
663
|
+
if (sent === 0) break;
|
|
664
|
+
ctx.paddingSent += sent;
|
|
665
|
+
const sleepTime = (ctx.totalFrames - startFrames) / ctx.sampleRate * 1e3 - (performance.now() - startTime);
|
|
666
|
+
if (sleepTime > 0) await this.#sleep(sleepTime);
|
|
667
|
+
}
|
|
668
|
+
this.#stopSync();
|
|
669
|
+
this.#context.logger.debug("[audio]", "Sending TEARDOWN...");
|
|
670
|
+
await this.#control.teardown(`/${this.#control.sessionId}`);
|
|
671
|
+
this.#context.logger.debug("[audio]", "TEARDOWN complete");
|
|
672
|
+
}
|
|
673
|
+
/**
|
|
674
|
+
* Prepares, paces and finishes the stream, then closes it. Sends extra packets when behind schedule.
|
|
675
|
+
*
|
|
676
|
+
* @param remoteAddress - Receiver address for the UDP connection.
|
|
677
|
+
*/
|
|
678
|
+
async stream(source, remoteAddress) {
|
|
679
|
+
const session = new AudioSourceSession(source);
|
|
680
|
+
this.#source = session;
|
|
681
|
+
try {
|
|
682
|
+
if (this.#closed) return;
|
|
683
|
+
const ctx = await this.prepare(remoteAddress);
|
|
684
|
+
if (this.#closed) return;
|
|
685
|
+
await session.start();
|
|
686
|
+
this.#context.logger.info("[audio]", "Starting audio stream...");
|
|
687
|
+
const packetCount = await streamWithTiming((firstPacket) => this.#sendPacket(session, firstPacket, ctx), {
|
|
688
|
+
sampleRate: ctx.sampleRate,
|
|
689
|
+
logger: this.#context.logger,
|
|
690
|
+
logPrefix: "[audio]"
|
|
691
|
+
});
|
|
692
|
+
this.#context.logger.info("[audio]", `Audio stream finished, sent ${packetCount} packets`);
|
|
693
|
+
this.#stopSync();
|
|
694
|
+
this.#context.logger.debug("[audio]", "Sending TEARDOWN...");
|
|
695
|
+
await this.#control.teardown(`/${this.#control.sessionId}`);
|
|
696
|
+
this.#context.logger.debug("[audio]", "TEARDOWN complete");
|
|
697
|
+
} finally {
|
|
698
|
+
this.close();
|
|
699
|
+
if (this.#source === session) this.#source = void 0;
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
/**
|
|
703
|
+
* Reads frames from the source and sends them as one RTP packet. Once the
|
|
704
|
+
* source is exhausted, it sends silence until the latency is padded.
|
|
705
|
+
*
|
|
706
|
+
* @param firstPacket - Sets the RTP marker bit.
|
|
707
|
+
* @param ctx - Stream context with the RTP state, updated in place.
|
|
708
|
+
* @returns Number of frames sent, or 0 when all padding has been sent.
|
|
709
|
+
*/
|
|
710
|
+
async #sendPacket(source, firstPacket, ctx) {
|
|
711
|
+
if (this.#closed || ctx.paddingSent >= ctx.latency) return 0;
|
|
712
|
+
let frames = await source.readFrames(352);
|
|
713
|
+
if (this.#closed) return 0;
|
|
714
|
+
if (!frames || frames.length === 0) {
|
|
715
|
+
frames = Buffer.alloc(ctx.packetSize, 0);
|
|
716
|
+
ctx.paddingSent += Math.floor(frames.length / ctx.frameSize);
|
|
717
|
+
} else if (frames.length < ctx.packetSize) {
|
|
718
|
+
const padded = Buffer.alloc(ctx.packetSize, 0);
|
|
719
|
+
frames.copy(padded);
|
|
720
|
+
frames = padded;
|
|
721
|
+
}
|
|
722
|
+
return this.#sendFrameBuffer(frames, firstPacket, ctx);
|
|
723
|
+
}
|
|
724
|
+
/**
|
|
725
|
+
* Builds encrypted RTP packets with optional RFC 2198 redundancy and retains them for retransmission.
|
|
726
|
+
*
|
|
727
|
+
* @param frames - PCM data, `packetSize` bytes.
|
|
728
|
+
* @param firstPacket - Sets the RTP marker bit.
|
|
729
|
+
* @returns Number of frames sent.
|
|
730
|
+
*/
|
|
731
|
+
#sendFrameBuffer(frames, firstPacket, ctx) {
|
|
732
|
+
const hasRedundancy = this.#redundancyCount > 0 && this.#previousFrames.length > 0;
|
|
733
|
+
const pt = hasRedundancy ? 97 : 96;
|
|
734
|
+
const rtpHeader = Buffer.allocUnsafe(12);
|
|
735
|
+
rtpHeader.writeUInt8(128, 0);
|
|
736
|
+
rtpHeader.writeUInt8(firstPacket ? pt | 128 : pt, 1);
|
|
737
|
+
rtpHeader.writeUInt16BE(ctx.rtpSeq, 2);
|
|
738
|
+
rtpHeader.writeUInt32BE(ctx.headTs >>> 0, 4);
|
|
739
|
+
rtpHeader.writeUInt32BE(this.#ssrc, 8);
|
|
740
|
+
let audioPayload;
|
|
741
|
+
if (hasRedundancy) {
|
|
742
|
+
const redundantFrames = this.#previousFrames.slice(-this.#redundancyCount);
|
|
743
|
+
const headers = [];
|
|
744
|
+
for (let i = 0; i < redundantFrames.length; i++) {
|
|
745
|
+
const tsOffset = (redundantFrames.length - i) * 352;
|
|
746
|
+
const blockLen = redundantFrames[i].length;
|
|
747
|
+
const header = Buffer.allocUnsafe(4);
|
|
748
|
+
header[0] = 224;
|
|
749
|
+
header[1] = tsOffset >> 6 & 255;
|
|
750
|
+
header[2] = (tsOffset & 63) << 2 | blockLen >> 8 & 3;
|
|
751
|
+
header[3] = blockLen & 255;
|
|
752
|
+
headers.push(header);
|
|
753
|
+
}
|
|
754
|
+
headers.push(Buffer.from([96]));
|
|
755
|
+
audioPayload = Buffer.concat([
|
|
756
|
+
...headers,
|
|
757
|
+
...redundantFrames,
|
|
758
|
+
frames
|
|
759
|
+
]);
|
|
760
|
+
} else audioPayload = frames;
|
|
761
|
+
this.#previousFrames.push(Buffer.from(frames));
|
|
762
|
+
if (this.#previousFrames.length > this.#redundancyCount) this.#previousFrames.shift();
|
|
763
|
+
const aad = rtpHeader.subarray(4, 12);
|
|
764
|
+
const payload = this.#encryptAudio(audioPayload, aad, this.#encryptionCounter++);
|
|
765
|
+
const packet = Buffer.concat([rtpHeader, payload]);
|
|
766
|
+
this.#storePacket(ctx.rtpSeq, packet);
|
|
767
|
+
const framesSent = Math.floor(frames.length / ctx.frameSize);
|
|
768
|
+
ctx.rtpSeq = ctx.rtpSeq + 1 & 65535;
|
|
769
|
+
ctx.headTs = ctx.headTs + framesSent >>> 0;
|
|
770
|
+
ctx.totalFrames += framesSent;
|
|
771
|
+
return this.#send(packet).then(() => {
|
|
772
|
+
this.#packetsSent++;
|
|
773
|
+
this.#totalBytesSent += packet.length;
|
|
774
|
+
this.#latencyManager?.reportSuccess();
|
|
775
|
+
if (this.#redundancyCount > 0 && this.#latencyManager && this.#packetsSent % 50 === 0) {
|
|
776
|
+
const recommended = this.#latencyManager.recommendedRedundancy;
|
|
777
|
+
if (recommended !== this.#redundancyCount) {
|
|
778
|
+
this.#context.logger.debug("[audio]", `Adjusting redundancy: ${this.#redundancyCount} → ${recommended}`);
|
|
779
|
+
this.#redundancyCount = recommended;
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
return framesSent;
|
|
783
|
+
});
|
|
784
|
+
}
|
|
785
|
+
/** Keeps a sent packet for retransmission and drops the oldest past {@link PACKET_BACKLOG_SIZE}. */
|
|
786
|
+
#storePacket(seqno, packet) {
|
|
787
|
+
this.#packetBacklog.set(seqno, packet);
|
|
788
|
+
if (this.#packetBacklog.size > PACKET_BACKLOG_SIZE) {
|
|
789
|
+
const oldestKey = this.#packetBacklog.keys().next().value;
|
|
790
|
+
if (oldestKey !== void 0) this.#packetBacklog.delete(oldestKey);
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
/**
|
|
794
|
+
* Returns a sent packet from the retransmission backlog.
|
|
795
|
+
*
|
|
796
|
+
* @returns The RTP packet with its header, or undefined once it left the backlog.
|
|
797
|
+
*/
|
|
798
|
+
getPacket(seqno) {
|
|
799
|
+
return this.#packetBacklog.get(seqno);
|
|
800
|
+
}
|
|
801
|
+
/**
|
|
802
|
+
* Encrypts an audio payload with ChaCha20-Poly1305 and the shared key. The
|
|
803
|
+
* 12-byte nonce holds the counter little-endian at offset 4.
|
|
804
|
+
*
|
|
805
|
+
* @param aad - RTP header bytes 4 to 12: timestamp and SSRC.
|
|
806
|
+
* @param seqNumber - Nonce counter.
|
|
807
|
+
* @returns Ciphertext, auth tag and the 8-byte nonce trailer.
|
|
808
|
+
* @throws EncryptionError if encryption has not been set up.
|
|
809
|
+
*/
|
|
810
|
+
#encryptAudio(data, aad, seqNumber) {
|
|
811
|
+
if (!this.#sharedKey) throw new EncryptionError("Encryption not setup.");
|
|
812
|
+
const nonceBytes = Buffer.alloc(12, 0);
|
|
813
|
+
nonceBytes.writeUInt32LE(seqNumber, 4);
|
|
814
|
+
const result = ChaCha20.encrypt(this.#sharedKey, nonceBytes, aad, data);
|
|
815
|
+
const nonceTrailer = nonceBytes.subarray(4, 12);
|
|
816
|
+
return Buffer.concat([
|
|
817
|
+
result.ciphertext,
|
|
818
|
+
result.authTag,
|
|
819
|
+
nonceTrailer
|
|
820
|
+
]);
|
|
821
|
+
}
|
|
822
|
+
#send(data) {
|
|
823
|
+
return new Promise((resolve, reject) => {
|
|
824
|
+
this.#dataSocket.send(data, (err) => err ? reject(err) : resolve());
|
|
825
|
+
});
|
|
826
|
+
}
|
|
827
|
+
#sleep(ms) {
|
|
828
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
829
|
+
}
|
|
830
|
+
/**
|
|
831
|
+
* Starts the periodic RTCP sync packets (type 0xD4). Each one tells the
|
|
832
|
+
* receiver our current RTP timestamp and its NTP wall-clock time; the
|
|
833
|
+
* first has the marker bit set.
|
|
834
|
+
*/
|
|
835
|
+
#startSync() {
|
|
836
|
+
if (this.#syncInterval) return;
|
|
837
|
+
let firstPacket = true;
|
|
838
|
+
const sendSync = () => {
|
|
839
|
+
if (!this.#controlSocket || !this.#streamContext || !this.#remoteControlPort) return;
|
|
840
|
+
const ctx = this.#streamContext;
|
|
841
|
+
const currentTime = rtpToNtp(ctx.headTs, ctx.sampleRate, this.#anchorRtp, this.#anchorNtp);
|
|
842
|
+
const [currentSec, currentFrac] = NTP.parts(currentTime);
|
|
843
|
+
const packet = Buffer.allocUnsafe(20);
|
|
844
|
+
packet.writeUInt8(firstPacket ? 144 : 128, 0);
|
|
845
|
+
packet.writeUInt8(212, 1);
|
|
846
|
+
packet.writeUInt16BE(7, 2);
|
|
847
|
+
packet.writeUInt32BE(ctx.headTs - ctx.latency >>> 0, 4);
|
|
848
|
+
packet.writeUInt32BE(currentSec, 8);
|
|
849
|
+
packet.writeUInt32BE(currentFrac, 12);
|
|
850
|
+
packet.writeUInt32BE(ctx.headTs >>> 0, 16);
|
|
851
|
+
firstPacket = false;
|
|
852
|
+
this.#controlSocket.send(packet, this.#remoteControlPort, this.#address, (err) => {
|
|
853
|
+
if (err) this.#context.logger.warn("[audio]", "Sync packet send failed", err);
|
|
854
|
+
});
|
|
855
|
+
};
|
|
856
|
+
sendSync();
|
|
857
|
+
this.#syncInterval = setInterval(sendSync, SYNC_INTERVAL);
|
|
858
|
+
}
|
|
859
|
+
/** Stops the RTCP sync packets and clears the stream context. */
|
|
860
|
+
#stopSync() {
|
|
861
|
+
if (this.#syncInterval) {
|
|
862
|
+
clearInterval(this.#syncInterval);
|
|
863
|
+
this.#syncInterval = void 0;
|
|
864
|
+
}
|
|
865
|
+
this.#streamContext = void 0;
|
|
866
|
+
}
|
|
867
|
+
/**
|
|
868
|
+
* Handles an RTCP control message from the receiver. Only type 0x55, a
|
|
869
|
+
* retransmit request (NACK), is handled.
|
|
870
|
+
*/
|
|
871
|
+
#onControlMessage(data, rinfo) {
|
|
872
|
+
if (data.length < 8 || data[0] >> 6 !== 2 || rinfo.address !== this.#address || rinfo.port !== this.#remoteControlPort) return;
|
|
873
|
+
if ((data[1] & 127) === 85) this.#retransmitPackets(data, rinfo);
|
|
874
|
+
}
|
|
875
|
+
/**
|
|
876
|
+
* Answers a NACK by resending each requested packet from the backlog in a
|
|
877
|
+
* retransmit response (type 0xD6). A packet that left the backlog gets an
|
|
878
|
+
* empty response, so the receiver stops waiting for it.
|
|
879
|
+
*
|
|
880
|
+
* @param data - NACK packet with the first sequence number and the count.
|
|
881
|
+
*/
|
|
882
|
+
#retransmitPackets(data, addr) {
|
|
883
|
+
const lostSeqno = data.readUInt16BE(4);
|
|
884
|
+
const lostPackets = Math.min(data.readUInt16BE(6), 512);
|
|
885
|
+
this.#latencyManager?.reportGlitch();
|
|
886
|
+
this.#retransmitRequests += lostPackets;
|
|
887
|
+
for (let i = 0; i < lostPackets; i++) {
|
|
888
|
+
const seqno = lostSeqno + i & 65535;
|
|
889
|
+
const packet = this.#packetBacklog.get(seqno);
|
|
890
|
+
if (packet) {
|
|
891
|
+
const originalSeqno = packet.subarray(2, 4);
|
|
892
|
+
const resp = Buffer.concat([
|
|
893
|
+
Buffer.from([128, 214]),
|
|
894
|
+
originalSeqno,
|
|
895
|
+
packet
|
|
896
|
+
]);
|
|
897
|
+
this.#controlSocket?.send(resp, addr.port, addr.address);
|
|
898
|
+
this.#retransmitsFulfilled++;
|
|
899
|
+
} else {
|
|
900
|
+
const seqBuf = Buffer.alloc(2);
|
|
901
|
+
seqBuf.writeUInt16BE(seqno);
|
|
902
|
+
const resp = Buffer.concat([
|
|
903
|
+
Buffer.from([128, 214]),
|
|
904
|
+
seqBuf,
|
|
905
|
+
Buffer.alloc(4)
|
|
906
|
+
]);
|
|
907
|
+
this.#controlSocket?.send(resp, addr.port, addr.address);
|
|
908
|
+
this.#retransmitsFailed++;
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
/**
|
|
913
|
+
* Stops the sync packets, closes both UDP sockets and clears the
|
|
914
|
+
* retransmission backlog. Safe to call more than once.
|
|
915
|
+
*/
|
|
916
|
+
close() {
|
|
917
|
+
this.#closed = true;
|
|
918
|
+
this.#source?.stop().catch((error) => this.#context.logger.error("[audio]", "Source cleanup failed", error));
|
|
919
|
+
this.#stopSync();
|
|
920
|
+
try {
|
|
921
|
+
this.#controlSocket?.removeAllListeners();
|
|
922
|
+
this.#controlSocket?.close();
|
|
923
|
+
} catch {}
|
|
924
|
+
this.#controlSocket = void 0;
|
|
925
|
+
try {
|
|
926
|
+
this.#dataSocket?.close();
|
|
927
|
+
} catch {}
|
|
928
|
+
this.#dataSocket = void 0;
|
|
929
|
+
this.#packetBacklog.clear();
|
|
930
|
+
}
|
|
931
|
+
};
|
|
932
|
+
|
|
933
|
+
//#endregion
|
|
934
|
+
//#region src/channels/encryption.ts
|
|
935
|
+
/** Frames we send carry at most this much plaintext. */
|
|
936
|
+
const FRAME_LENGTH = 1024;
|
|
937
|
+
function nonce(counter) {
|
|
938
|
+
return buildNonce(counter, "counter-at-4");
|
|
939
|
+
}
|
|
940
|
+
/**
|
|
941
|
+
* Decrypts the complete frames at the start of `data`: `[2-byte LE length] [ciphertext] [16-byte
|
|
942
|
+
* auth tag]`, where the length is the AAD. Callers keep the unconsumed ciphertext tail for the
|
|
943
|
+
* next read.
|
|
944
|
+
*
|
|
945
|
+
* @throws If a frame has length zero, or a frame fails authentication.
|
|
946
|
+
*/
|
|
947
|
+
function chacha20DecryptFrames(state, data) {
|
|
948
|
+
const output = [];
|
|
949
|
+
let offset = 0;
|
|
950
|
+
while (offset + 2 <= data.length) {
|
|
951
|
+
const length = data.readUInt16LE(offset);
|
|
952
|
+
if (length === 0) throw new Error("Invalid encrypted frame length");
|
|
953
|
+
const end = offset + length + 18;
|
|
954
|
+
if (end > data.length) break;
|
|
955
|
+
const plaintext = ChaCha20.decrypt(state.readKey, nonce(state.readCount), data.subarray(offset, offset + 2), data.subarray(offset + 2, end - 16), data.subarray(end - 16, end));
|
|
956
|
+
state.readCount++;
|
|
957
|
+
output.push(plaintext);
|
|
958
|
+
offset = end;
|
|
959
|
+
}
|
|
960
|
+
return {
|
|
961
|
+
data: Buffer.concat(output),
|
|
962
|
+
consumed: offset
|
|
963
|
+
};
|
|
964
|
+
}
|
|
965
|
+
/** Encrypts `data` in frames of at most 1024 bytes, each with its 2-byte LE length as AAD. */
|
|
966
|
+
function chacha20Encrypt(state, data) {
|
|
967
|
+
const result = [];
|
|
968
|
+
for (let offset = 0; offset < data.length;) {
|
|
969
|
+
const frame = data.subarray(offset, offset + FRAME_LENGTH);
|
|
970
|
+
offset += frame.length;
|
|
971
|
+
const leLength = Buffer.allocUnsafe(2);
|
|
972
|
+
leLength.writeUInt16LE(frame.length, 0);
|
|
973
|
+
const encrypted = ChaCha20.encrypt(state.writeKey, nonce(state.writeCount++), leLength, frame);
|
|
974
|
+
result.push(leLength, encrypted.ciphertext, encrypted.authTag);
|
|
975
|
+
}
|
|
976
|
+
return Buffer.concat(result);
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
//#endregion
|
|
980
|
+
//#region src/channels/control.ts
|
|
981
|
+
/** Request line protocol of play queue commands: they go as HTTP/1.1 on the RTSP connection. */
|
|
982
|
+
const COMMAND_PROTOCOL = "HTTP/1.1";
|
|
983
|
+
/** Slightly less than the two-second feedback interval, so requests never overlap. */
|
|
984
|
+
const FEEDBACK_TIMEOUT = 1900;
|
|
985
|
+
/**
|
|
986
|
+
* The RTSP control connection of an AirPlay session. Every request carries the session headers
|
|
987
|
+
* (Active-Remote, DACP-ID, X-Apple-Session-ID); after pair-verify the connection switches to
|
|
988
|
+
* ChaCha20-Poly1305 frames. Requests resolve with any status, the caller checks it.
|
|
989
|
+
*/
|
|
990
|
+
var ControlChannel = class extends RtspClient {
|
|
991
|
+
/** DACP remote identifier, a random unsigned 32-bit integer in decimal. */
|
|
992
|
+
get activeRemoteId() {
|
|
993
|
+
return this.#activeRemoteId;
|
|
994
|
+
}
|
|
995
|
+
/** DACP identifier, 16 uppercase hex characters. */
|
|
996
|
+
get dacpId() {
|
|
997
|
+
return this.#dacpId;
|
|
998
|
+
}
|
|
999
|
+
/** Identifies the session in RTSP URIs (`/<sessionId>`) and the `X-Apple-Session-ID` header. */
|
|
1000
|
+
get sessionId() {
|
|
1001
|
+
return this.#sessionId;
|
|
1002
|
+
}
|
|
1003
|
+
get isEncrypted() {
|
|
1004
|
+
return !!this.#encryptionState;
|
|
1005
|
+
}
|
|
1006
|
+
/** Settles once the TEARDOWN in flight is answered, times out or loses its connection; undefined when none is. */
|
|
1007
|
+
get pendingTeardown() {
|
|
1008
|
+
return this.#pendingTeardown;
|
|
1009
|
+
}
|
|
1010
|
+
#activeRemoteId;
|
|
1011
|
+
#dacpId;
|
|
1012
|
+
#sessionId;
|
|
1013
|
+
#closeReason;
|
|
1014
|
+
#encryptionState;
|
|
1015
|
+
#pendingTeardown;
|
|
1016
|
+
constructor(context, address, port) {
|
|
1017
|
+
super(context, address, port);
|
|
1018
|
+
this.#activeRemoteId = generateActiveRemoteId();
|
|
1019
|
+
this.#dacpId = generateDacpId();
|
|
1020
|
+
this.#sessionId = generateSessionId();
|
|
1021
|
+
this.on("closed", (reason) => this.#closeReason = reason);
|
|
1022
|
+
}
|
|
1023
|
+
/** Switches to encrypted frames with the control keys from pair-verify or a transient pairing. */
|
|
1024
|
+
enableEncryption(readKey, writeKey) {
|
|
1025
|
+
this.#encryptionState = new EncryptionState(readKey, writeKey);
|
|
1026
|
+
}
|
|
1027
|
+
getDefaultHeaders() {
|
|
1028
|
+
return {
|
|
1029
|
+
"Active-Remote": this.#activeRemoteId,
|
|
1030
|
+
"DACP-ID": this.#dacpId,
|
|
1031
|
+
"User-Agent": `AirPlay/${resolveIdentity(this.context).sourceVersion}`,
|
|
1032
|
+
"X-Apple-ProtocolVersion": 1,
|
|
1033
|
+
"X-Apple-Session-ID": this.#sessionId
|
|
1034
|
+
};
|
|
1035
|
+
}
|
|
1036
|
+
transformIncoming(data) {
|
|
1037
|
+
if (!this.#encryptionState) return data;
|
|
1038
|
+
return chacha20DecryptFrames(this.#encryptionState, data);
|
|
1039
|
+
}
|
|
1040
|
+
transformOutgoing(data) {
|
|
1041
|
+
if (!this.#encryptionState) return data;
|
|
1042
|
+
return chacha20Encrypt(this.#encryptionState, data);
|
|
1043
|
+
}
|
|
1044
|
+
/**
|
|
1045
|
+
* Sends a play queue command as `POST /command`, the command as a binary plist in `params.data`.
|
|
1046
|
+
*
|
|
1047
|
+
* @param streamId - Stream ID from the SETUP of the remote control stream.
|
|
1048
|
+
* @param sessionUUID - Session UUID from the first SETUP, sent as `X-Apple-Session-ID`.
|
|
1049
|
+
*/
|
|
1050
|
+
async command(streamId, payload, sessionUUID) {
|
|
1051
|
+
const data = payload instanceof ArrayBuffer ? payload : Plist.serialize(payload);
|
|
1052
|
+
return await this.exchange("POST", "/command", {
|
|
1053
|
+
headers: {
|
|
1054
|
+
"X-Apple-Session-ID": sessionUUID,
|
|
1055
|
+
"X-Apple-StreamID": String(streamId)
|
|
1056
|
+
},
|
|
1057
|
+
body: { params: { data } },
|
|
1058
|
+
protocol: COMMAND_PROTOCOL,
|
|
1059
|
+
allowError: true
|
|
1060
|
+
});
|
|
1061
|
+
}
|
|
1062
|
+
/** The keepalive a receiver expects every two seconds, optionally with stats in the body. */
|
|
1063
|
+
async feedback(stats) {
|
|
1064
|
+
return await this.post("/feedback", stats, void 0, FEEDBACK_TIMEOUT);
|
|
1065
|
+
}
|
|
1066
|
+
async flush(uri, headers) {
|
|
1067
|
+
return await this.exchange("FLUSH", uri, {
|
|
1068
|
+
headers,
|
|
1069
|
+
allowError: true
|
|
1070
|
+
});
|
|
1071
|
+
}
|
|
1072
|
+
/** Flushes buffered audio, optionally limited by flushFromSeq/TS and flushUntilSeq/TS headers. */
|
|
1073
|
+
async flushBuffered(uri, headers = {}) {
|
|
1074
|
+
return await this.exchange("FLUSHBUFFERED", uri, {
|
|
1075
|
+
headers,
|
|
1076
|
+
allowError: true
|
|
1077
|
+
});
|
|
1078
|
+
}
|
|
1079
|
+
async get(path, headers = {}, timeout = HTTP_TIMEOUT) {
|
|
1080
|
+
return await this.exchange("GET", path, {
|
|
1081
|
+
headers,
|
|
1082
|
+
timeout,
|
|
1083
|
+
allowError: true
|
|
1084
|
+
});
|
|
1085
|
+
}
|
|
1086
|
+
/**
|
|
1087
|
+
* Reads a receiver property, such as `Volume`, `VolumeDB`, `IsMuted`, `ReceiverDeviceIsPlaying`,
|
|
1088
|
+
* `AudioLatencyMs`, `ClusterUUID`, `UsePTPClock` or `dmcp.device-volume`. The body holds the value,
|
|
1089
|
+
* usually as a plist.
|
|
1090
|
+
*/
|
|
1091
|
+
async getProperty(property) {
|
|
1092
|
+
return await this.get(`/getProperty?${property}`);
|
|
1093
|
+
}
|
|
1094
|
+
async post(path, body, headers = {}, timeout = HTTP_TIMEOUT) {
|
|
1095
|
+
return await this.exchange("POST", path, {
|
|
1096
|
+
headers,
|
|
1097
|
+
body,
|
|
1098
|
+
timeout,
|
|
1099
|
+
allowError: true
|
|
1100
|
+
});
|
|
1101
|
+
}
|
|
1102
|
+
async put(path, body, headers = {}, timeout = HTTP_TIMEOUT) {
|
|
1103
|
+
return await this.exchange("PUT", path, {
|
|
1104
|
+
headers,
|
|
1105
|
+
body,
|
|
1106
|
+
timeout,
|
|
1107
|
+
allowError: true
|
|
1108
|
+
});
|
|
1109
|
+
}
|
|
1110
|
+
async record(path, headers = {}, timeout = HTTP_TIMEOUT) {
|
|
1111
|
+
return await this.exchange("RECORD", path, {
|
|
1112
|
+
headers,
|
|
1113
|
+
timeout,
|
|
1114
|
+
allowError: true
|
|
1115
|
+
});
|
|
1116
|
+
}
|
|
1117
|
+
/** Seeks URL playback to a position in seconds. */
|
|
1118
|
+
async scrub(position) {
|
|
1119
|
+
return await this.exchange("POST", `/scrub?position=${position.toFixed(6)}`, { allowError: true });
|
|
1120
|
+
}
|
|
1121
|
+
/** Sets the audio routing mode, such as `default`, `moviePlayback` or `spoken`. */
|
|
1122
|
+
async setAudioMode(mode) {
|
|
1123
|
+
const body = Plist.serialize({ audioMode: mode });
|
|
1124
|
+
return await this.exchange("POST", "/audioMode", {
|
|
1125
|
+
body: Buffer.from(body),
|
|
1126
|
+
headers: { "Content-Type": "application/x-apple-binary-plist" },
|
|
1127
|
+
allowError: true
|
|
1128
|
+
});
|
|
1129
|
+
}
|
|
1130
|
+
/** Sends `SET_PARAMETER` on the session with a `text/parameters` body; stream volume is set this way, in dB. */
|
|
1131
|
+
async setParameter(parameter, value) {
|
|
1132
|
+
return await this.exchange("SET_PARAMETER", `/${this.sessionId}`, {
|
|
1133
|
+
contentType: "text/parameters",
|
|
1134
|
+
body: `${parameter}: ${value}\r\n`,
|
|
1135
|
+
allowError: true
|
|
1136
|
+
});
|
|
1137
|
+
}
|
|
1138
|
+
/** Writes a receiver property; see {@link getProperty} for the known keys. */
|
|
1139
|
+
async setProperty(property, body) {
|
|
1140
|
+
return await this.put(`/setProperty?${property}`, body);
|
|
1141
|
+
}
|
|
1142
|
+
async setup(path, body, headers = {}, timeout = HTTP_TIMEOUT) {
|
|
1143
|
+
return await this.exchange("SETUP", path, {
|
|
1144
|
+
headers,
|
|
1145
|
+
body,
|
|
1146
|
+
timeout,
|
|
1147
|
+
allowError: true
|
|
1148
|
+
});
|
|
1149
|
+
}
|
|
1150
|
+
async setVolume(volume) {
|
|
1151
|
+
return await this.exchange("POST", `/volume?volume=${volume.toFixed(6)}`, { allowError: true });
|
|
1152
|
+
}
|
|
1153
|
+
/** Stops URL playback. */
|
|
1154
|
+
async stop() {
|
|
1155
|
+
return await this.exchange("POST", "/stop", { allowError: true });
|
|
1156
|
+
}
|
|
1157
|
+
/**
|
|
1158
|
+
* Ends the session on the receiver. A receiver that closes this connection before it answers
|
|
1159
|
+
* has ended the session itself, so that counts as done and resolves with undefined.
|
|
1160
|
+
*/
|
|
1161
|
+
async teardown(path, headers = {}, timeout = HTTP_TIMEOUT) {
|
|
1162
|
+
const exchange = this.exchange("TEARDOWN", path, {
|
|
1163
|
+
headers,
|
|
1164
|
+
timeout,
|
|
1165
|
+
allowError: true
|
|
1166
|
+
});
|
|
1167
|
+
const settled = exchange.then(() => void 0, () => void 0);
|
|
1168
|
+
this.#pendingTeardown = settled;
|
|
1169
|
+
settled.then(() => {
|
|
1170
|
+
if (this.#pendingTeardown === settled) this.#pendingTeardown = void 0;
|
|
1171
|
+
});
|
|
1172
|
+
try {
|
|
1173
|
+
return await exchange;
|
|
1174
|
+
} catch (err) {
|
|
1175
|
+
if (this.#closeReason !== "remote") throw err;
|
|
1176
|
+
this.context.logger.debug("[rtsp]", "The receiver closed the control connection before answering TEARDOWN, the session has ended.");
|
|
1177
|
+
return;
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
};
|
|
1181
|
+
|
|
1182
|
+
//#endregion
|
|
1183
|
+
//#region src/channels/encrypted.ts
|
|
1184
|
+
/** Upper bound for both receive buffers; a peer that sends more is broken or hostile. */
|
|
1185
|
+
const MAX_BUFFER_SIZE = 4 * 1024 * 1024;
|
|
1186
|
+
/**
|
|
1187
|
+
* A TCP channel next to the control channel that is encrypted from its first byte, in AirPlay's
|
|
1188
|
+
* frame format (2-byte LE length as AAD, nonce with the counter at offset 4).
|
|
1189
|
+
*
|
|
1190
|
+
* Ciphertext and plaintext have separate buffers: a partial TCP delivery leaves ciphertext waiting
|
|
1191
|
+
* until its frame is complete, so it can never end up in the plaintext a subclass parses. A frame
|
|
1192
|
+
* that does not decrypt or parse ends the channel with reason `error`.
|
|
1193
|
+
*/
|
|
1194
|
+
var EncryptedChannel = class extends EncryptionAwareConnection {
|
|
1195
|
+
#buffer = Buffer.alloc(0);
|
|
1196
|
+
#encryptedBuffer = Buffer.alloc(0);
|
|
1197
|
+
constructor(context, address, port, keys) {
|
|
1198
|
+
super(context, address, port);
|
|
1199
|
+
this.enableEncryption(keys.readKey, keys.writeKey);
|
|
1200
|
+
const connection = this;
|
|
1201
|
+
connection.on("data", (data) => this.#onData(data));
|
|
1202
|
+
connection.on("closed", () => this.#reset());
|
|
1203
|
+
}
|
|
1204
|
+
/** Encrypts and writes a plaintext frame. */
|
|
1205
|
+
writeEncrypted(data) {
|
|
1206
|
+
this.write(chacha20Encrypt(this.encryption, data));
|
|
1207
|
+
}
|
|
1208
|
+
#onData(data) {
|
|
1209
|
+
try {
|
|
1210
|
+
if (this.#encryptedBuffer.length + data.length > MAX_BUFFER_SIZE) throw new Error("Encrypted stream buffer limit exceeded.");
|
|
1211
|
+
this.#encryptedBuffer = Buffer.concat([this.#encryptedBuffer, data]);
|
|
1212
|
+
const decrypted = chacha20DecryptFrames(this.encryption, this.#encryptedBuffer);
|
|
1213
|
+
this.#encryptedBuffer = Buffer.from(this.#encryptedBuffer.subarray(decrypted.consumed));
|
|
1214
|
+
this.#buffer = Buffer.concat([this.#buffer, decrypted.data]);
|
|
1215
|
+
if (this.#buffer.length > MAX_BUFFER_SIZE) throw new Error("Plaintext stream buffer limit exceeded.");
|
|
1216
|
+
const consumed = this.receive(this.#buffer, data.byteLength);
|
|
1217
|
+
this.#buffer = this.#buffer.subarray(consumed);
|
|
1218
|
+
} catch (err) {
|
|
1219
|
+
this.#reset();
|
|
1220
|
+
this.context.logger.error(`[${this.constructor.name}]`, "Failed to read from the channel.", err);
|
|
1221
|
+
this.destroy(err instanceof Error ? err : new Error(String(err)));
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
#reset() {
|
|
1225
|
+
this.#buffer = Buffer.alloc(0);
|
|
1226
|
+
this.#encryptedBuffer = Buffer.alloc(0);
|
|
1227
|
+
}
|
|
1228
|
+
};
|
|
1229
|
+
|
|
1230
|
+
//#endregion
|
|
1231
|
+
//#region src/channels/data.ts
|
|
1232
|
+
/** Size of the frame header in bytes. */
|
|
1233
|
+
const DATA_HEADER_LENGTH = 32;
|
|
1234
|
+
/** Largest frame we accept; anything bigger is a broken length field. */
|
|
1235
|
+
const MAX_FRAME_LENGTH = 16 * 1024 * 1024;
|
|
1236
|
+
/** MRP exchange timeout in milliseconds. */
|
|
1237
|
+
const EXCHANGE_TIMEOUT = 5e3;
|
|
1238
|
+
/**
|
|
1239
|
+
* The MRP channel: protobuf messages over the dedicated data socket. A 32-byte header wraps a
|
|
1240
|
+
* plist whose `params.data` holds varint-prefixed ProtocolMessages. {@link exchange} waits for the
|
|
1241
|
+
* reply with the same identifier, {@link send} expects none; every incoming message becomes an
|
|
1242
|
+
* event. Keys come from the pair-verify secret and the seed of the SETUP that announced the port.
|
|
1243
|
+
*/
|
|
1244
|
+
var DataChannel = class extends EncryptedChannel {
|
|
1245
|
+
#outstanding = /* @__PURE__ */ new Map();
|
|
1246
|
+
#seqno;
|
|
1247
|
+
constructor(context, address, port, sharedSecret, seed) {
|
|
1248
|
+
super(context, address, port, HKDF.deriveEncryptionKeys(sharedSecret, `DataStream-Salt${seed}`, "DataStream-Input-Encryption-Key", "DataStream-Output-Encryption-Key"));
|
|
1249
|
+
this.#seqno = 4294967296n + BigInt(randomInt32());
|
|
1250
|
+
this.on("closed", () => this.#rejectAll(new ConnectionClosedError()));
|
|
1251
|
+
this.on("error", (error) => {
|
|
1252
|
+
this.context.logger.error("[data]", "Data channel error.", error);
|
|
1253
|
+
this.#rejectAll(error);
|
|
1254
|
+
});
|
|
1255
|
+
}
|
|
1256
|
+
/**
|
|
1257
|
+
* Sends a message and resolves with the reply that carries the same `identifier`, or the
|
|
1258
|
+
* first message of the same type when the message has no identifier.
|
|
1259
|
+
*
|
|
1260
|
+
* @throws {TimeoutError} When no reply arrives in time.
|
|
1261
|
+
* @throws {ConnectionClosedError} When the channel ends first.
|
|
1262
|
+
*/
|
|
1263
|
+
exchange(message, timeout = EXCHANGE_TIMEOUT) {
|
|
1264
|
+
const protocolMessage = Array.isArray(message) ? message[0] : message;
|
|
1265
|
+
const identifier = protocolMessage.identifier || `type_${protocolMessage.type}`;
|
|
1266
|
+
if (this.signal.aborted) return Promise.reject(new ConnectionClosedError());
|
|
1267
|
+
return new Promise((resolve, reject) => {
|
|
1268
|
+
const timer = setTimeout(() => {
|
|
1269
|
+
this.#outstanding.delete(identifier);
|
|
1270
|
+
reject(new TimeoutError(`Exchange timed out for ${identifier}`));
|
|
1271
|
+
}, timeout);
|
|
1272
|
+
this.#outstanding.set(identifier, {
|
|
1273
|
+
resolve,
|
|
1274
|
+
reject,
|
|
1275
|
+
timer
|
|
1276
|
+
});
|
|
1277
|
+
try {
|
|
1278
|
+
this.send(message);
|
|
1279
|
+
} catch (err) {
|
|
1280
|
+
this.#outstanding.delete(identifier);
|
|
1281
|
+
clearTimeout(timer);
|
|
1282
|
+
reject(err);
|
|
1283
|
+
}
|
|
1284
|
+
});
|
|
1285
|
+
}
|
|
1286
|
+
/**
|
|
1287
|
+
* Sends a message without waiting for a reply.
|
|
1288
|
+
*
|
|
1289
|
+
* @throws {ConnectionClosedError} When the channel has ended.
|
|
1290
|
+
*/
|
|
1291
|
+
send(message) {
|
|
1292
|
+
if (this.signal.aborted) throw new ConnectionClosedError();
|
|
1293
|
+
if (Array.isArray(message)) message = message[0];
|
|
1294
|
+
const bytes = toBinary(ProtocolMessageSchema, message, { writeUnknownFields: true });
|
|
1295
|
+
const payload = Buffer.concat([Buffer.from(encodeVarint(bytes.byteLength)), bytes]);
|
|
1296
|
+
const plistPayload = Buffer.from(Plist.serialize({ params: { data: payload.buffer.slice(payload.byteOffset, payload.byteOffset + payload.byteLength) } }));
|
|
1297
|
+
const header = buildHeader$1(DATA_HEADER_LENGTH + plistPayload.byteLength, this.#seqno++);
|
|
1298
|
+
if (reporter.tapsTraffic || reporter.accepts("raw")) {
|
|
1299
|
+
const decoded = describeProtocolMessage(message);
|
|
1300
|
+
this.context.logger.raw("[data]", `Sending message. ${decoded.typeName}`, decoded);
|
|
1301
|
+
this.#tap("out", decoded, bytes);
|
|
1302
|
+
}
|
|
1303
|
+
this.writeEncrypted(Buffer.concat([header, plistPayload]));
|
|
1304
|
+
}
|
|
1305
|
+
/** Frames are a 32-byte header and a plist payload. `sync` frames get a `rply`; `rply` frames only acknowledge transport. */
|
|
1306
|
+
receive(plaintext, received) {
|
|
1307
|
+
let offset = 0;
|
|
1308
|
+
while (plaintext.byteLength - offset >= DATA_HEADER_LENGTH) {
|
|
1309
|
+
const header = plaintext.subarray(offset, offset + DATA_HEADER_LENGTH);
|
|
1310
|
+
const totalLength = header.readUint32BE();
|
|
1311
|
+
if (totalLength < DATA_HEADER_LENGTH || totalLength > MAX_FRAME_LENGTH) throw new Error(`Invalid DataStream frame length: ${totalLength}`);
|
|
1312
|
+
if (plaintext.byteLength - offset < totalLength) {
|
|
1313
|
+
this.context.logger.debug("[data]", `Data packet is too short needed=${totalLength} available=${plaintext.byteLength - offset} receivedLength=${received}`);
|
|
1314
|
+
break;
|
|
1315
|
+
}
|
|
1316
|
+
const frame = plaintext.subarray(offset + DATA_HEADER_LENGTH, offset + totalLength);
|
|
1317
|
+
const plist = frame.length > 0 ? Plist.parse(frame.buffer.slice(frame.byteOffset, frame.byteOffset + frame.byteLength)) : null;
|
|
1318
|
+
const command = header.toString("ascii", 4, 8);
|
|
1319
|
+
offset += totalLength;
|
|
1320
|
+
if (plist?.params?.data) for (const message of parseMessages(Buffer.from(plist.params.data))) {
|
|
1321
|
+
if (reporter.tapsTraffic || reporter.accepts("raw")) {
|
|
1322
|
+
const decoded = describeProtocolMessage(message);
|
|
1323
|
+
this.context.logger.raw("[data]", `Received message. ${decoded.typeName}`, decoded);
|
|
1324
|
+
this.#tap("in", decoded);
|
|
1325
|
+
}
|
|
1326
|
+
this.#handleMessage(message);
|
|
1327
|
+
}
|
|
1328
|
+
if (command === "sync") this.#reply(parseHeaderSeqno(header));
|
|
1329
|
+
}
|
|
1330
|
+
return offset;
|
|
1331
|
+
}
|
|
1332
|
+
#reply(seqno) {
|
|
1333
|
+
this.context.logger.raw("[data]", `Sending reply packet seqno=${seqno}`);
|
|
1334
|
+
this.writeEncrypted(buildReply(seqno));
|
|
1335
|
+
}
|
|
1336
|
+
/** Emits `rawMessage`, settles a matching exchange, then emits the typed event. */
|
|
1337
|
+
#handleMessage(message) {
|
|
1338
|
+
this.emit("rawMessage", message);
|
|
1339
|
+
const identifier = message.identifier || `type_${message.type}`;
|
|
1340
|
+
const outstanding = this.#outstanding.get(identifier);
|
|
1341
|
+
if (outstanding) {
|
|
1342
|
+
this.#outstanding.delete(identifier);
|
|
1343
|
+
clearTimeout(outstanding.timer);
|
|
1344
|
+
outstanding.resolve(message);
|
|
1345
|
+
}
|
|
1346
|
+
const kind = messageKind(message.type);
|
|
1347
|
+
if (!kind) {
|
|
1348
|
+
if (message.type !== 0) this.context.logger.warn("[data]", `Unknown message type ${message.type}.`);
|
|
1349
|
+
return;
|
|
1350
|
+
}
|
|
1351
|
+
const { extension, name: event, level } = kind;
|
|
1352
|
+
let decoded;
|
|
1353
|
+
try {
|
|
1354
|
+
decoded = getExtension(message, extension);
|
|
1355
|
+
} catch (err) {
|
|
1356
|
+
this.context.logger.error("[data]", `Failed to parse extension for message type ${message.type}:`, err);
|
|
1357
|
+
return;
|
|
1358
|
+
}
|
|
1359
|
+
this.context.logger[level]("[data]", event, decoded);
|
|
1360
|
+
try {
|
|
1361
|
+
this.emit(event, decoded);
|
|
1362
|
+
} catch (err) {
|
|
1363
|
+
this.context.logger.error("[data]", `A listener for ${event} failed:`, err);
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
#rejectAll(error) {
|
|
1367
|
+
for (const { reject, timer } of this.#outstanding.values()) {
|
|
1368
|
+
clearTimeout(timer);
|
|
1369
|
+
reject(error);
|
|
1370
|
+
}
|
|
1371
|
+
this.#outstanding.clear();
|
|
1372
|
+
}
|
|
1373
|
+
#tap(direction, decoded, bytes) {
|
|
1374
|
+
if (!reporter.tapsTraffic) return;
|
|
1375
|
+
this.context.logger.traffic("dataStream", direction, String(decoded.typeName), decoded, bytes);
|
|
1376
|
+
}
|
|
1377
|
+
};
|
|
1378
|
+
|
|
1379
|
+
//#endregion
|
|
1380
|
+
//#region src/playback/playQueue.ts
|
|
1381
|
+
/** Client type of the remote control stream (RCS) that carries play queue commands. */
|
|
1382
|
+
const RCS_CLIENT_TYPE_UUID = "A6B27562-B43A-4F2D-B75F-82391E250194";
|
|
1383
|
+
/** Channel suffixes tried in order for the RCS SETUP; macOS sends RCS-2. */
|
|
1384
|
+
const RCS_CHANNEL_SUFFIXES = ["RCS-1", "RCS-2"];
|
|
1385
|
+
/** Milliseconds to wait for the first `playing` state after `setRate`. */
|
|
1386
|
+
const START_TIMEOUT = 3e4;
|
|
1387
|
+
/** CMTime that marks a time as invalid, used for the pause times of a streaming item. */
|
|
1388
|
+
const INVALID_TIME = {
|
|
1389
|
+
flags: 0,
|
|
1390
|
+
value: 0,
|
|
1391
|
+
epoch: 0,
|
|
1392
|
+
timescale: 0
|
|
1393
|
+
};
|
|
1394
|
+
const FCUP_MAX_RESPONSE_SIZE = 8 * 1024 * 1024;
|
|
1395
|
+
const FCUP_MAX_SESSION_SIZE = 32 * 1024 * 1024;
|
|
1396
|
+
const FCUP_FETCH_TIMEOUT = 2e4;
|
|
1397
|
+
const FCUP_MAX_IN_FLIGHT = 4;
|
|
1398
|
+
const FCUP_SKIPPED_REQUEST_HEADERS = ["accept-encoding", "host"];
|
|
1399
|
+
const FCUP_SKIPPED_RESPONSE_HEADERS = [
|
|
1400
|
+
"connection",
|
|
1401
|
+
"content-encoding",
|
|
1402
|
+
"content-length",
|
|
1403
|
+
"keep-alive",
|
|
1404
|
+
"transfer-encoding"
|
|
1405
|
+
];
|
|
1406
|
+
/**
|
|
1407
|
+
* URL playback through the receiver's play queue: a remote control stream on the control
|
|
1408
|
+
* connection carries the commands, the event stream reports the playback state back.
|
|
1409
|
+
* Also answers `unhandledURL` (FCUP) requests by fetching the URL on behalf of the receiver.
|
|
1410
|
+
*/
|
|
1411
|
+
var PlayQueueSession = class {
|
|
1412
|
+
/** Stream ID the receiver assigned to the remote control stream, once set up. */
|
|
1413
|
+
get streamId() {
|
|
1414
|
+
return this.#streamId;
|
|
1415
|
+
}
|
|
1416
|
+
/** The last playback state the receiver reported. */
|
|
1417
|
+
get state() {
|
|
1418
|
+
return this.#state;
|
|
1419
|
+
}
|
|
1420
|
+
/** Playback info built from the last `playing` event, with the position extrapolated to now. */
|
|
1421
|
+
get lastInfo() {
|
|
1422
|
+
const playing = this.#playing;
|
|
1423
|
+
if (!playing) return this.#state ? { state: this.#state } : {};
|
|
1424
|
+
const isPlaying = this.#state === "playing";
|
|
1425
|
+
let position = playing.position;
|
|
1426
|
+
if (position !== void 0 && isPlaying) {
|
|
1427
|
+
position += playing.rate * (performance.now() - playing.receivedAt) / 1e3;
|
|
1428
|
+
if (playing.duration !== void 0) position = Math.min(position, playing.duration);
|
|
1429
|
+
}
|
|
1430
|
+
return {
|
|
1431
|
+
duration: playing.duration,
|
|
1432
|
+
position,
|
|
1433
|
+
rate: isPlaying ? playing.rate : 0,
|
|
1434
|
+
readyToPlay: true,
|
|
1435
|
+
state: this.#state
|
|
1436
|
+
};
|
|
1437
|
+
}
|
|
1438
|
+
#context;
|
|
1439
|
+
#controlStream;
|
|
1440
|
+
#sessionUUID;
|
|
1441
|
+
#senderMac;
|
|
1442
|
+
#fetchAbort = new AbortController();
|
|
1443
|
+
#fetchWaiters = [];
|
|
1444
|
+
#end;
|
|
1445
|
+
#resolveEnd;
|
|
1446
|
+
#rejectEnd;
|
|
1447
|
+
#settled = false;
|
|
1448
|
+
#started = false;
|
|
1449
|
+
#fetchesInFlight = 0;
|
|
1450
|
+
#fetchedBytes = 0;
|
|
1451
|
+
#itemUUID;
|
|
1452
|
+
#playing;
|
|
1453
|
+
#startTimer;
|
|
1454
|
+
#state;
|
|
1455
|
+
#streamId;
|
|
1456
|
+
/**
|
|
1457
|
+
* @param controlStream - Encrypted control channel the commands go out on.
|
|
1458
|
+
* @param eventStream - Event channel the receiver reports on.
|
|
1459
|
+
* @param sessionUUID - Session UUID from the first SETUP, sent as `X-Apple-Session-ID`.
|
|
1460
|
+
* @param senderMac - The `macAddress` sent in the first SETUP; prefix of the RCS channel ID.
|
|
1461
|
+
*/
|
|
1462
|
+
constructor(context, controlStream, eventStream, sessionUUID, senderMac) {
|
|
1463
|
+
this.#context = context;
|
|
1464
|
+
this.#controlStream = controlStream;
|
|
1465
|
+
this.#sessionUUID = sessionUUID;
|
|
1466
|
+
this.#senderMac = senderMac;
|
|
1467
|
+
this.#end = new Promise((resolve, reject) => {
|
|
1468
|
+
this.#resolveEnd = resolve;
|
|
1469
|
+
this.#rejectEnd = reject;
|
|
1470
|
+
});
|
|
1471
|
+
this.#end.catch(() => void 0);
|
|
1472
|
+
eventStream.on("playQueue", (command) => this.handleEvent(command));
|
|
1473
|
+
eventStream.on("closed", () => this.#onStreamClose());
|
|
1474
|
+
controlStream.on("closed", () => this.#onStreamClose());
|
|
1475
|
+
}
|
|
1476
|
+
/**
|
|
1477
|
+
* Sets up the remote control stream (SETUP type 130), trying each channel suffix once.
|
|
1478
|
+
*
|
|
1479
|
+
* @returns `false` when the receiver refused every attempt, so the caller can fall back to `/play`.
|
|
1480
|
+
*/
|
|
1481
|
+
async setup() {
|
|
1482
|
+
for (const suffix of RCS_CHANNEL_SUFFIXES) {
|
|
1483
|
+
const streamId = await this.#setupStream(`${this.#senderMac}-${suffix}`);
|
|
1484
|
+
if (streamId !== void 0) {
|
|
1485
|
+
this.#streamId = streamId;
|
|
1486
|
+
this.#context.logger.info("[play-queue]", `Remote control stream ${streamId} set up on channel ${suffix}.`);
|
|
1487
|
+
return true;
|
|
1488
|
+
}
|
|
1489
|
+
}
|
|
1490
|
+
return false;
|
|
1491
|
+
}
|
|
1492
|
+
/**
|
|
1493
|
+
* Queues the URL and starts playback.
|
|
1494
|
+
*
|
|
1495
|
+
* @param url - Media URL the receiver loads.
|
|
1496
|
+
* @param position - Start position in seconds.
|
|
1497
|
+
* @returns `false` when the receiver answered `insertPlayQueueItem` with 4xx or 5xx, so the caller can fall back to `/play`.
|
|
1498
|
+
* @throws {PlaybackError} When a later command fails or reports an `errorCode`.
|
|
1499
|
+
*/
|
|
1500
|
+
async start(url, position) {
|
|
1501
|
+
const itemUUID = randomUUID().toUpperCase();
|
|
1502
|
+
this.#itemUUID = itemUUID;
|
|
1503
|
+
const insert = await this.#send({
|
|
1504
|
+
type: "insertPlayQueueItem",
|
|
1505
|
+
item: buildItem(itemUUID, url, position)
|
|
1506
|
+
});
|
|
1507
|
+
if (insert.status >= 400) {
|
|
1508
|
+
this.#context.logger.warn("[play-queue]", `insertPlayQueueItem failed: ${insert.status}`);
|
|
1509
|
+
return false;
|
|
1510
|
+
}
|
|
1511
|
+
await this.#check("insertPlayQueueItem", insert);
|
|
1512
|
+
const commands = [
|
|
1513
|
+
{
|
|
1514
|
+
type: "setProperty",
|
|
1515
|
+
property: "isInterestedInDateRange",
|
|
1516
|
+
value: true,
|
|
1517
|
+
item: { uuid: itemUUID }
|
|
1518
|
+
},
|
|
1519
|
+
{
|
|
1520
|
+
type: "setProperty",
|
|
1521
|
+
property: "actionAtItemEnd",
|
|
1522
|
+
value: 1
|
|
1523
|
+
},
|
|
1524
|
+
{
|
|
1525
|
+
type: "setRate",
|
|
1526
|
+
rate: 1
|
|
1527
|
+
}
|
|
1528
|
+
];
|
|
1529
|
+
for (const command of commands) await this.#check(String(command.type), await this.#send(command));
|
|
1530
|
+
if (!this.#started && !this.#settled) this.#startTimer = setTimeout(() => this.#fail(new PlaybackError("Playback did not start within 30 seconds.")), START_TIMEOUT);
|
|
1531
|
+
return true;
|
|
1532
|
+
}
|
|
1533
|
+
/**
|
|
1534
|
+
* Handles a play queue command from the receiver. Bound to the event stream's `playQueue` event.
|
|
1535
|
+
*
|
|
1536
|
+
* @param command - The decoded `params.data` of the request.
|
|
1537
|
+
*/
|
|
1538
|
+
handleEvent(command) {
|
|
1539
|
+
if (this.#settled) return;
|
|
1540
|
+
if (command.kind === "request" && command.type === "unhandledURL") {
|
|
1541
|
+
this.#answerUnhandledURL(command);
|
|
1542
|
+
return;
|
|
1543
|
+
}
|
|
1544
|
+
if (command.type === "playbackState") this.#onPlaybackState(command);
|
|
1545
|
+
else if (command.type === "notification") this.#onNotification(command);
|
|
1546
|
+
}
|
|
1547
|
+
/**
|
|
1548
|
+
* Resolves when playback ends and rejects with a {@link PlaybackError} when it fails.
|
|
1549
|
+
*
|
|
1550
|
+
* @param signal - Stops waiting early, without an error, when aborted.
|
|
1551
|
+
*/
|
|
1552
|
+
async waitForEnd(signal) {
|
|
1553
|
+
if (!signal) return await this.#end;
|
|
1554
|
+
if (signal.aborted) return;
|
|
1555
|
+
await new Promise((resolve, reject) => {
|
|
1556
|
+
const onAbort = () => resolve();
|
|
1557
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
1558
|
+
this.#end.then(resolve, reject).finally(() => signal.removeEventListener("abort", onAbort));
|
|
1559
|
+
});
|
|
1560
|
+
}
|
|
1561
|
+
/** Ends the session: stops listening, aborts open fetches and resolves {@link waitForEnd}. */
|
|
1562
|
+
close() {
|
|
1563
|
+
this.#settle();
|
|
1564
|
+
}
|
|
1565
|
+
async #setupStream(channelID) {
|
|
1566
|
+
const response = await this.#controlStream.setup(`/${this.#controlStream.sessionId}`, { streams: [{
|
|
1567
|
+
type: 130,
|
|
1568
|
+
controlType: 1,
|
|
1569
|
+
channelID,
|
|
1570
|
+
clientUUID: randomUUID().toUpperCase(),
|
|
1571
|
+
clientTypeUUID: RCS_CLIENT_TYPE_UUID
|
|
1572
|
+
}] });
|
|
1573
|
+
if (response.status !== 200) {
|
|
1574
|
+
this.#context.logger.warn("[play-queue]", `SETUP of remote control stream ${channelID} failed: ${response.status}`);
|
|
1575
|
+
return;
|
|
1576
|
+
}
|
|
1577
|
+
const streamId = (parsePlist(await response.arrayBuffer())?.streams)?.[0]?.streamID;
|
|
1578
|
+
if (typeof streamId !== "number" && typeof streamId !== "bigint") {
|
|
1579
|
+
this.#context.logger.warn("[play-queue]", `SETUP of remote control stream ${channelID} returned no streamID.`);
|
|
1580
|
+
return;
|
|
1581
|
+
}
|
|
1582
|
+
return Number(streamId);
|
|
1583
|
+
}
|
|
1584
|
+
async #send(command) {
|
|
1585
|
+
if (this.#streamId === void 0) throw new PlaybackError("The remote control stream is not set up.");
|
|
1586
|
+
return await this.#controlStream.command(this.#streamId, serializeCommand(command), this.#sessionUUID);
|
|
1587
|
+
}
|
|
1588
|
+
async #check(name, response) {
|
|
1589
|
+
if (!response.ok) throw new PlaybackError(`Play queue command ${name} failed: ${response.status}`);
|
|
1590
|
+
const body = parsePlist(await response.arrayBuffer());
|
|
1591
|
+
const errorCode = (body ? decodeNestedCommand(body) ?? body : void 0)?.errorCode;
|
|
1592
|
+
if (errorCode !== void 0 && errorCode !== 0) throw new PlaybackError(`Play queue command ${name} failed with errorCode ${String(errorCode)}.`);
|
|
1593
|
+
}
|
|
1594
|
+
#onPlaybackState(command) {
|
|
1595
|
+
const params = isRecord(command.params) ? command.params : {};
|
|
1596
|
+
const rawState = params.playbackState ?? command.name;
|
|
1597
|
+
if (typeof rawState !== "string") return;
|
|
1598
|
+
const state = rawState.toLowerCase();
|
|
1599
|
+
const reason = params.reason ?? command.reason;
|
|
1600
|
+
const position = this.lastInfo.position;
|
|
1601
|
+
this.#state = state;
|
|
1602
|
+
this.#context.logger.info("[play-queue]", `Playback state: ${state}${reason !== void 0 ? ` (${String(reason)})` : ""}`);
|
|
1603
|
+
if (state === "playing") {
|
|
1604
|
+
this.#started = true;
|
|
1605
|
+
clearTimeout(this.#startTimer);
|
|
1606
|
+
this.#playing = {
|
|
1607
|
+
position: toSeconds(params.position),
|
|
1608
|
+
duration: toSeconds(params.duration),
|
|
1609
|
+
rate: toSeconds(params.rate) ?? 1,
|
|
1610
|
+
receivedAt: performance.now()
|
|
1611
|
+
};
|
|
1612
|
+
return;
|
|
1613
|
+
}
|
|
1614
|
+
if (state === "paused" && this.#playing) {
|
|
1615
|
+
this.#playing = {
|
|
1616
|
+
...this.#playing,
|
|
1617
|
+
position,
|
|
1618
|
+
receivedAt: performance.now()
|
|
1619
|
+
};
|
|
1620
|
+
return;
|
|
1621
|
+
}
|
|
1622
|
+
if (state === "stopped" && this.#started) this.#settle();
|
|
1623
|
+
}
|
|
1624
|
+
#onNotification(command) {
|
|
1625
|
+
const name = command.name;
|
|
1626
|
+
this.#context.logger.debug("[play-queue]", `Notification: ${String(name)}`, command.params);
|
|
1627
|
+
if (name === "itemPlayedToEnd") this.#settle();
|
|
1628
|
+
else if (name === "itemFailedToPlayToEnd") this.#fail(new PlaybackError(`The receiver failed to play the item: ${describe(command.params)}`));
|
|
1629
|
+
}
|
|
1630
|
+
#onStreamClose() {
|
|
1631
|
+
if (this.#settled) return;
|
|
1632
|
+
if (this.#started) {
|
|
1633
|
+
this.#context.logger.info("[play-queue]", "Stream closed, assuming playback ended.");
|
|
1634
|
+
this.#settle();
|
|
1635
|
+
return;
|
|
1636
|
+
}
|
|
1637
|
+
this.#fail(new PlaybackError("The connection closed before playback started."));
|
|
1638
|
+
}
|
|
1639
|
+
async #answerUnhandledURL(command) {
|
|
1640
|
+
const request = isRecord(command.request) ? command.request : void 0;
|
|
1641
|
+
const url = request?.FCUP_Response_URL;
|
|
1642
|
+
const messageId = command.messageID;
|
|
1643
|
+
if (!request || typeof url !== "string" || messageId === void 0) {
|
|
1644
|
+
this.#context.logger.warn("[play-queue]", "Ignoring malformed unhandledURL request.", command);
|
|
1645
|
+
return;
|
|
1646
|
+
}
|
|
1647
|
+
const reply = async (status, headers, data) => {
|
|
1648
|
+
const response = await this.#send({
|
|
1649
|
+
kind: "response",
|
|
1650
|
+
messageID: messageId,
|
|
1651
|
+
type: "unhandledURL",
|
|
1652
|
+
response: {
|
|
1653
|
+
FCUP_Response_URL: url,
|
|
1654
|
+
FCUP_Response_RequestID: request.FCUP_Response_RequestID ?? 0,
|
|
1655
|
+
FCUP_Response_StatusCode: status,
|
|
1656
|
+
FCUP_Response_Headers: headers,
|
|
1657
|
+
FCUP_Response_Data: data
|
|
1658
|
+
}
|
|
1659
|
+
});
|
|
1660
|
+
if (!response.ok) this.#context.logger.warn("[play-queue]", `Receiver rejected the unhandledURL response: ${response.status}`);
|
|
1661
|
+
};
|
|
1662
|
+
try {
|
|
1663
|
+
this.#context.logger.info("[play-queue]", `Receiver asks us to fetch ${url}`);
|
|
1664
|
+
if (request.FCUP_Response_IsContentKeyRequest === true) {
|
|
1665
|
+
await reply(501, {}, /* @__PURE__ */ new ArrayBuffer(0));
|
|
1666
|
+
this.#fail(new PlaybackError(`The receiver asked for a FairPlay content key (${url}); protected content is not supported.`));
|
|
1667
|
+
return;
|
|
1668
|
+
}
|
|
1669
|
+
if (!isHttpUrl(url)) {
|
|
1670
|
+
await reply(403, {}, /* @__PURE__ */ new ArrayBuffer(0));
|
|
1671
|
+
this.#fail(new PlaybackError(`The receiver asked to fetch ${url}; only http and https URLs are fetched.`));
|
|
1672
|
+
return;
|
|
1673
|
+
}
|
|
1674
|
+
let result;
|
|
1675
|
+
try {
|
|
1676
|
+
result = await this.#fetch(url, requestHeaders(request.FCUP_Response_Headers));
|
|
1677
|
+
} catch (err) {
|
|
1678
|
+
if (this.#settled) return;
|
|
1679
|
+
this.#context.logger.warn("[play-queue]", `Failed to fetch ${url} for the receiver.`, err);
|
|
1680
|
+
await reply(502, {}, /* @__PURE__ */ new ArrayBuffer(0));
|
|
1681
|
+
this.#fail(new PlaybackError(`Could not fetch ${url} for the receiver: ${err.message}`));
|
|
1682
|
+
return;
|
|
1683
|
+
}
|
|
1684
|
+
if (this.#settled) return;
|
|
1685
|
+
this.#context.logger.info("[play-queue]", `Fetched ${url} for the receiver: ${result.status} (${result.data.byteLength} bytes)`);
|
|
1686
|
+
if (result.status >= 200 && result.status < 300 && !isPlaylist(result.data)) this.#context.logger.warn("[play-queue]", `${url} is not an HLS playlist; the receiver will probably not play it.`);
|
|
1687
|
+
await reply(result.status, result.headers, result.data);
|
|
1688
|
+
} catch (err) {
|
|
1689
|
+
this.#context.logger.error("[play-queue]", `Failed to answer the unhandledURL request for ${url}.`, err);
|
|
1690
|
+
}
|
|
1691
|
+
}
|
|
1692
|
+
async #fetch(url, headers) {
|
|
1693
|
+
await this.#acquireFetchSlot();
|
|
1694
|
+
try {
|
|
1695
|
+
const signal = AbortSignal.any([this.#fetchAbort.signal, AbortSignal.timeout(FCUP_FETCH_TIMEOUT)]);
|
|
1696
|
+
signal.throwIfAborted();
|
|
1697
|
+
const response = await fetch(url, {
|
|
1698
|
+
headers,
|
|
1699
|
+
redirect: "follow",
|
|
1700
|
+
signal
|
|
1701
|
+
});
|
|
1702
|
+
const data = await this.#readBody(response);
|
|
1703
|
+
const replyHeaders = {};
|
|
1704
|
+
response.headers.forEach((value, key) => {
|
|
1705
|
+
if (!FCUP_SKIPPED_RESPONSE_HEADERS.includes(key.toLowerCase())) replyHeaders[key] = value;
|
|
1706
|
+
});
|
|
1707
|
+
replyHeaders["Content-Length"] = String(data.byteLength);
|
|
1708
|
+
return {
|
|
1709
|
+
status: response.status,
|
|
1710
|
+
headers: replyHeaders,
|
|
1711
|
+
data
|
|
1712
|
+
};
|
|
1713
|
+
} finally {
|
|
1714
|
+
this.#releaseFetchSlot();
|
|
1715
|
+
}
|
|
1716
|
+
}
|
|
1717
|
+
async #readBody(response) {
|
|
1718
|
+
const declaredLength = Number(response.headers.get("content-length") ?? 0);
|
|
1719
|
+
if (declaredLength > FCUP_MAX_RESPONSE_SIZE) {
|
|
1720
|
+
await response.body?.cancel();
|
|
1721
|
+
throw new PlaybackError(`Response too large (${declaredLength} bytes).`);
|
|
1722
|
+
}
|
|
1723
|
+
const reader = response.body?.getReader();
|
|
1724
|
+
if (!reader) return /* @__PURE__ */ new ArrayBuffer(0);
|
|
1725
|
+
const chunks = [];
|
|
1726
|
+
let length = 0;
|
|
1727
|
+
while (true) {
|
|
1728
|
+
const { done, value } = await reader.read();
|
|
1729
|
+
if (done) break;
|
|
1730
|
+
length += value.byteLength;
|
|
1731
|
+
this.#fetchedBytes += value.byteLength;
|
|
1732
|
+
if (length > FCUP_MAX_RESPONSE_SIZE || this.#fetchedBytes > FCUP_MAX_SESSION_SIZE) {
|
|
1733
|
+
await reader.cancel();
|
|
1734
|
+
throw new PlaybackError(length > FCUP_MAX_RESPONSE_SIZE ? `Response larger than ${FCUP_MAX_RESPONSE_SIZE} bytes.` : `Fetched more than ${FCUP_MAX_SESSION_SIZE} bytes in this session.`);
|
|
1735
|
+
}
|
|
1736
|
+
chunks.push(value);
|
|
1737
|
+
}
|
|
1738
|
+
const data = new Uint8Array(length);
|
|
1739
|
+
let offset = 0;
|
|
1740
|
+
for (const chunk of chunks) {
|
|
1741
|
+
data.set(chunk, offset);
|
|
1742
|
+
offset += chunk.byteLength;
|
|
1743
|
+
}
|
|
1744
|
+
return data.buffer;
|
|
1745
|
+
}
|
|
1746
|
+
async #acquireFetchSlot() {
|
|
1747
|
+
if (this.#fetchesInFlight < FCUP_MAX_IN_FLIGHT) {
|
|
1748
|
+
this.#fetchesInFlight++;
|
|
1749
|
+
return;
|
|
1750
|
+
}
|
|
1751
|
+
await new Promise((resolve) => this.#fetchWaiters.push(resolve));
|
|
1752
|
+
}
|
|
1753
|
+
#releaseFetchSlot() {
|
|
1754
|
+
const next = this.#fetchWaiters.shift();
|
|
1755
|
+
if (next) next();
|
|
1756
|
+
else this.#fetchesInFlight--;
|
|
1757
|
+
}
|
|
1758
|
+
#fail(error) {
|
|
1759
|
+
if (this.#settled) return;
|
|
1760
|
+
this.#context.logger.error("[play-queue]", error.message);
|
|
1761
|
+
this.#rejectEnd(error);
|
|
1762
|
+
this.#settle();
|
|
1763
|
+
}
|
|
1764
|
+
#settle() {
|
|
1765
|
+
if (this.#settled) return;
|
|
1766
|
+
this.#settled = true;
|
|
1767
|
+
clearTimeout(this.#startTimer);
|
|
1768
|
+
this.#resolveEnd();
|
|
1769
|
+
this.#fetchAbort.abort();
|
|
1770
|
+
for (const waiter of this.#fetchWaiters.splice(0)) {
|
|
1771
|
+
this.#fetchesInFlight++;
|
|
1772
|
+
waiter();
|
|
1773
|
+
}
|
|
1774
|
+
this.#context.logger.debug("[play-queue]", `Session for item ${this.#itemUUID ?? "?"} ended.`);
|
|
1775
|
+
}
|
|
1776
|
+
};
|
|
1777
|
+
/**
|
|
1778
|
+
* Decodes the binary plist in `params.data` of a `POST /command` body.
|
|
1779
|
+
*
|
|
1780
|
+
* @returns The inner command, or undefined when the body carries none.
|
|
1781
|
+
*/
|
|
1782
|
+
function decodeNestedCommand(body) {
|
|
1783
|
+
if (!isRecord(body) || !isRecord(body.params) || !isBinaryPlist(body.params.data)) return;
|
|
1784
|
+
return parsePlist(body.params.data) ?? void 0;
|
|
1785
|
+
}
|
|
1786
|
+
/**
|
|
1787
|
+
* Picks the item's `mediaType`, `streaming` for an HLS playlist. macOS sends `streaming` for HLS,
|
|
1788
|
+
* and third party receivers keep a live playlist in `loading` as `file`. Apple TV is not verified
|
|
1789
|
+
* with `streaming`.
|
|
1790
|
+
*/
|
|
1791
|
+
function mediaTypeFor(url) {
|
|
1792
|
+
try {
|
|
1793
|
+
return new URL(url).pathname.toLowerCase().endsWith(".m3u8") ? "streaming" : "file";
|
|
1794
|
+
} catch {
|
|
1795
|
+
return "file";
|
|
1796
|
+
}
|
|
1797
|
+
}
|
|
1798
|
+
function buildItem(itemUUID, url, position) {
|
|
1799
|
+
const mediaType = mediaTypeFor(url);
|
|
1800
|
+
const item = {
|
|
1801
|
+
uuid: itemUUID,
|
|
1802
|
+
"Content-Location": url,
|
|
1803
|
+
mediaType,
|
|
1804
|
+
"Start-Position": {
|
|
1805
|
+
flags: 1,
|
|
1806
|
+
value: Math.round(position * 1e3),
|
|
1807
|
+
epoch: 0,
|
|
1808
|
+
timescale: 1e3
|
|
1809
|
+
}
|
|
1810
|
+
};
|
|
1811
|
+
if (mediaType === "streaming") {
|
|
1812
|
+
item.timeToPausePlayback = INVALID_TIME;
|
|
1813
|
+
item.timeToPauseBuffering = INVALID_TIME;
|
|
1814
|
+
}
|
|
1815
|
+
return item;
|
|
1816
|
+
}
|
|
1817
|
+
/**
|
|
1818
|
+
* Serializes a command to the binary plist that goes in `params.data`. With {@link RATE_AS_REAL}, a
|
|
1819
|
+
* whole `setRate` rate is written with a placeholder real whose bytes are then overwritten.
|
|
1820
|
+
*/
|
|
1821
|
+
function serializeCommand(command) {
|
|
1822
|
+
const rate = command.rate;
|
|
1823
|
+
if (command.type !== "setRate" || typeof rate !== "number" || !Number.isInteger(rate)) return Plist.serialize(command);
|
|
1824
|
+
const placeholder = .25;
|
|
1825
|
+
const data = Plist.serialize({
|
|
1826
|
+
...command,
|
|
1827
|
+
rate: placeholder
|
|
1828
|
+
});
|
|
1829
|
+
const view = new DataView(data);
|
|
1830
|
+
for (let offset = 0; offset + 9 <= data.byteLength; offset++) if (view.getUint8(offset) === 35 && view.getFloat64(offset + 1) === placeholder) {
|
|
1831
|
+
view.setFloat64(offset + 1, rate);
|
|
1832
|
+
return data;
|
|
1833
|
+
}
|
|
1834
|
+
throw new PlaybackError("Could not encode the playback rate.");
|
|
1835
|
+
}
|
|
1836
|
+
function requestHeaders(headers) {
|
|
1837
|
+
const result = {};
|
|
1838
|
+
if (!isRecord(headers)) return result;
|
|
1839
|
+
for (const [key, value] of Object.entries(headers)) if (!FCUP_SKIPPED_REQUEST_HEADERS.includes(key.toLowerCase())) result[key] = String(value);
|
|
1840
|
+
return result;
|
|
1841
|
+
}
|
|
1842
|
+
/** Reads a number or a CMTime dictionary (`value` / `timescale`) as seconds. */
|
|
1843
|
+
function toSeconds(value) {
|
|
1844
|
+
if (typeof value === "number") return value;
|
|
1845
|
+
if (isRecord(value) && typeof value.value === "number" && typeof value.timescale === "number" && value.timescale > 0) return value.value / value.timescale;
|
|
1846
|
+
}
|
|
1847
|
+
function isHttpUrl(url) {
|
|
1848
|
+
try {
|
|
1849
|
+
const protocol = new URL(url).protocol;
|
|
1850
|
+
return protocol === "http:" || protocol === "https:";
|
|
1851
|
+
} catch {
|
|
1852
|
+
return false;
|
|
1853
|
+
}
|
|
1854
|
+
}
|
|
1855
|
+
function isPlaylist(data) {
|
|
1856
|
+
return Buffer.from(data, 0, Math.min(data.byteLength, 64)).toString("utf8").trimStart().startsWith("#EXTM3U");
|
|
1857
|
+
}
|
|
1858
|
+
function isBinaryPlist(data) {
|
|
1859
|
+
return data instanceof ArrayBuffer && data.byteLength >= 8 && Buffer.from(data, 0, 8).toString("latin1") === "bplist00";
|
|
1860
|
+
}
|
|
1861
|
+
function parsePlist(data) {
|
|
1862
|
+
if (data.byteLength === 0) return;
|
|
1863
|
+
try {
|
|
1864
|
+
const value = Plist.parse(data);
|
|
1865
|
+
return isRecord(value) ? value : void 0;
|
|
1866
|
+
} catch {
|
|
1867
|
+
return;
|
|
1868
|
+
}
|
|
1869
|
+
}
|
|
1870
|
+
function isRecord(value) {
|
|
1871
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) && !(value instanceof ArrayBuffer);
|
|
1872
|
+
}
|
|
1873
|
+
function describe(value) {
|
|
1874
|
+
try {
|
|
1875
|
+
return JSON.stringify(value) ?? "unknown error";
|
|
1876
|
+
} catch {
|
|
1877
|
+
return String(value);
|
|
1878
|
+
}
|
|
1879
|
+
}
|
|
1880
|
+
|
|
1881
|
+
//#endregion
|
|
1882
|
+
//#region src/channels/event.ts
|
|
1883
|
+
/**
|
|
1884
|
+
* The reverse RTSP channel: the receiver is the client here and we answer its requests. Every
|
|
1885
|
+
* request gets `200 OK`; `POST /command` bodies become events.
|
|
1886
|
+
*/
|
|
1887
|
+
var EventChannel = class extends EncryptedChannel {
|
|
1888
|
+
constructor(context, address, port, sharedSecret) {
|
|
1889
|
+
super(context, address, port, eventKeys(sharedSecret));
|
|
1890
|
+
}
|
|
1891
|
+
/** Sends a response to a request of the receiver. */
|
|
1892
|
+
respond(status, statusText, headers, body) {
|
|
1893
|
+
const data = buildResponse({
|
|
1894
|
+
status,
|
|
1895
|
+
statusText,
|
|
1896
|
+
headers,
|
|
1897
|
+
body
|
|
1898
|
+
});
|
|
1899
|
+
this.context.logger.traffic("eventStream", "out", `${status} ${statusText}`, { headers }, data);
|
|
1900
|
+
this.writeEncrypted(data);
|
|
1901
|
+
}
|
|
1902
|
+
receive(plaintext) {
|
|
1903
|
+
let offset = 0;
|
|
1904
|
+
while (plaintext.byteLength > offset) {
|
|
1905
|
+
const result = parseRequest(plaintext.subarray(offset));
|
|
1906
|
+
if (result === null) break;
|
|
1907
|
+
if (reporter.tapsTraffic) this.context.logger.traffic("eventStream", "in", `${result.method} ${result.path}`, {
|
|
1908
|
+
headers: result.headers,
|
|
1909
|
+
body: withNestedCommand(decodePlist(result.body))
|
|
1910
|
+
}, Buffer.from(plaintext.subarray(offset, offset + result.requestLength)));
|
|
1911
|
+
offset += result.requestLength;
|
|
1912
|
+
this.#handle(result.method, result.path, result.headers, result.body);
|
|
1913
|
+
}
|
|
1914
|
+
return offset;
|
|
1915
|
+
}
|
|
1916
|
+
#handle(method, path, headers, body) {
|
|
1917
|
+
const key = `${method} ${path}`;
|
|
1918
|
+
if (key !== "POST /command") {
|
|
1919
|
+
this.context.logger.warn("[event]", "No handler for url", key);
|
|
1920
|
+
this.respond(200, "OK", { "CSeq": headers["CSeq"] ?? 0 });
|
|
1921
|
+
return;
|
|
1922
|
+
}
|
|
1923
|
+
const data = Plist.parse(body.buffer.slice(body.byteOffset, body.byteOffset + body.byteLength));
|
|
1924
|
+
const commandType = data.type;
|
|
1925
|
+
this.context.logger.info("[event]", "Received command:", commandType ?? "unknown", data);
|
|
1926
|
+
this.emit("command", data);
|
|
1927
|
+
if (commandType === "cycleUsePickedRoute" || commandType === "duckAudio") this.emit("duckAudio", data);
|
|
1928
|
+
else if (commandType === "unduckAudio") this.emit("unduckAudio", data);
|
|
1929
|
+
else if (commandType === "died") this.emit("sessionDied");
|
|
1930
|
+
this.respond(200, "OK", {
|
|
1931
|
+
"Audio-Latency": 0,
|
|
1932
|
+
"CSeq": headers["CSeq"] ?? 0
|
|
1933
|
+
});
|
|
1934
|
+
const playQueueCommand = decodeNestedCommand(data);
|
|
1935
|
+
if (playQueueCommand) {
|
|
1936
|
+
this.context.logger.info("[event]", "Received play queue command:", playQueueCommand.type ?? "unknown", playQueueCommand);
|
|
1937
|
+
this.emit("playQueue", playQueueCommand);
|
|
1938
|
+
}
|
|
1939
|
+
}
|
|
1940
|
+
};
|
|
1941
|
+
/**
|
|
1942
|
+
* The HKDF infos are named from the receiver's side, because the receiver originates this
|
|
1943
|
+
* connection. The keys are swapped on purpose: `Events-Write-Encryption-Key` is what the
|
|
1944
|
+
* receiver writes, which we read.
|
|
1945
|
+
*/
|
|
1946
|
+
function eventKeys(sharedSecret) {
|
|
1947
|
+
const { readKey, writeKey } = HKDF.deriveEncryptionKeys(sharedSecret, "Events-Salt", "Events-Read-Encryption-Key", "Events-Write-Encryption-Key");
|
|
1948
|
+
return {
|
|
1949
|
+
readKey: writeKey,
|
|
1950
|
+
writeKey: readKey
|
|
1951
|
+
};
|
|
1952
|
+
}
|
|
1953
|
+
function decodePlist(body) {
|
|
1954
|
+
if (body.byteLength === 0) return;
|
|
1955
|
+
try {
|
|
1956
|
+
return Plist.parse(body.buffer.slice(body.byteOffset, body.byteOffset + body.byteLength));
|
|
1957
|
+
} catch {
|
|
1958
|
+
return;
|
|
1959
|
+
}
|
|
1960
|
+
}
|
|
1961
|
+
/** Replaces the binary plist in `params.data` by its decoded command, for the traffic sink. */
|
|
1962
|
+
function withNestedCommand(body) {
|
|
1963
|
+
const command = decodeNestedCommand(body);
|
|
1964
|
+
if (!command) return body;
|
|
1965
|
+
const outer = body;
|
|
1966
|
+
return {
|
|
1967
|
+
...outer,
|
|
1968
|
+
params: {
|
|
1969
|
+
...outer.params,
|
|
1970
|
+
data: command
|
|
1971
|
+
}
|
|
1972
|
+
};
|
|
1973
|
+
}
|
|
1974
|
+
|
|
1975
|
+
//#endregion
|
|
1976
|
+
//#region src/info.ts
|
|
1977
|
+
/**
|
|
1978
|
+
* Fetches `/info`. A status other than 200 is not an error: what an earlier `/info` said stays, and
|
|
1979
|
+
* without one the receiver has no features and SETUP bodies carry our own source version. Fields
|
|
1980
|
+
* the answer lacks keep their earlier value too.
|
|
1981
|
+
*/
|
|
1982
|
+
async function fetchInfo(control, context, previous) {
|
|
1983
|
+
const ourVersion = String(resolveIdentity(context).sourceVersion);
|
|
1984
|
+
const response = await control.get("/info");
|
|
1985
|
+
if (response.status !== 200) {
|
|
1986
|
+
context.logger.warn("[protocol]", `GET /info failed: ${response.status}`);
|
|
1987
|
+
return previous ?? {
|
|
1988
|
+
info: {},
|
|
1989
|
+
features: 0n,
|
|
1990
|
+
sourceVersion: ourVersion
|
|
1991
|
+
};
|
|
1992
|
+
}
|
|
1993
|
+
const info = Plist.parse(await response.arrayBuffer());
|
|
1994
|
+
const receiverVersion = info.sourceVersion;
|
|
1995
|
+
const features = info.features != null || info.featuresEx != null ? parseReceiverFeatures(info) : previous?.features ?? 0n;
|
|
1996
|
+
context.logger.info("[protocol]", `Receiver: ${info.name ?? "unknown"}, model=${info.model ?? "?"}, sourceVersion=${receiverVersion ?? "?"}`);
|
|
1997
|
+
context.logger.info("[protocol]", `Receiver features: ${describeFlags(features).join(", ")}`);
|
|
1998
|
+
if (info.initialVolume != null) context.logger.info("[protocol]", `Receiver initial volume: ${info.initialVolume}`);
|
|
1999
|
+
if (receiverVersion && compareVersions(ourVersion, receiverVersion) > 0) {
|
|
2000
|
+
context.logger.info("[protocol]", `Capping sourceVersion from ${ourVersion} to ${receiverVersion}`);
|
|
2001
|
+
return {
|
|
2002
|
+
info,
|
|
2003
|
+
features,
|
|
2004
|
+
sourceVersion: receiverVersion
|
|
2005
|
+
};
|
|
2006
|
+
}
|
|
2007
|
+
return {
|
|
2008
|
+
info,
|
|
2009
|
+
features,
|
|
2010
|
+
sourceVersion: previous?.sourceVersion ?? ourVersion
|
|
2011
|
+
};
|
|
2012
|
+
}
|
|
2013
|
+
function compareVersions(a, b) {
|
|
2014
|
+
const pa = a.split(".").map(Number);
|
|
2015
|
+
const pb = b.split(".").map(Number);
|
|
2016
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
2017
|
+
const na = pa[i] ?? 0;
|
|
2018
|
+
const nb = pb[i] ?? 0;
|
|
2019
|
+
if (na !== nb) return na - nb;
|
|
2020
|
+
}
|
|
2021
|
+
return 0;
|
|
2022
|
+
}
|
|
2023
|
+
|
|
2024
|
+
//#endregion
|
|
2025
|
+
//#region src/pairing.ts
|
|
2026
|
+
/** Posts each pairing message as an octet-stream body; the HTTP status is not checked, the TLV carries errors. */
|
|
2027
|
+
function rtspTransport(control, path, hkp) {
|
|
2028
|
+
return async (payload) => {
|
|
2029
|
+
const response = await control.post(path, payload, {
|
|
2030
|
+
"Content-Type": "application/octet-stream",
|
|
2031
|
+
"X-Apple-HKP": hkp.toString()
|
|
2032
|
+
});
|
|
2033
|
+
return Buffer.from(await response.arrayBuffer());
|
|
2034
|
+
};
|
|
2035
|
+
}
|
|
2036
|
+
/** `/pair-pin-start` goes first for both a PIN and a transient pairing. */
|
|
2037
|
+
async function pinStart(control, hkp) {
|
|
2038
|
+
const response = await control.post("/pair-pin-start", null, {
|
|
2039
|
+
"Content-Type": "application/octet-stream",
|
|
2040
|
+
"X-Apple-HKP": hkp.toString()
|
|
2041
|
+
});
|
|
2042
|
+
if (response.status !== 200) throw new PairingError(`Cannot start pairing session. ${response.status} ${response.statusText} ${await response.text()}`);
|
|
2043
|
+
}
|
|
2044
|
+
/** Pair-verify over `/pair-verify`, followed by the control keys. */
|
|
2045
|
+
async function verifyPairing(control, context, credentials) {
|
|
2046
|
+
const keys = await new PairVerify(context, rtspTransport(control, "/pair-verify", 3)).start(credentials);
|
|
2047
|
+
return {
|
|
2048
|
+
...deriveControlKeys(keys.sharedSecret),
|
|
2049
|
+
pairingId: keys.pairingId,
|
|
2050
|
+
sharedSecret: keys.sharedSecret
|
|
2051
|
+
};
|
|
2052
|
+
}
|
|
2053
|
+
/** Pair-setup M1 to M4 with the transient flag, for receivers that need no PIN (HomePod). */
|
|
2054
|
+
async function transientPairing(control, context) {
|
|
2055
|
+
const setup = new PairSetup(context, rtspTransport(control, "/pair-setup", 4));
|
|
2056
|
+
await setup.start();
|
|
2057
|
+
await pinStart(control, 4);
|
|
2058
|
+
return await setup.transient();
|
|
2059
|
+
}
|
|
2060
|
+
/**
|
|
2061
|
+
* Starts a PIN pairing: connects, fetches `/info` and asks the receiver to show its PIN. Finish
|
|
2062
|
+
* with {@link PinPairing.pin}, which runs pair-setup M1 to M6 and returns the long-term credentials.
|
|
2063
|
+
*
|
|
2064
|
+
* @throws {PairingError} When the receiver refuses to start pairing.
|
|
2065
|
+
*/
|
|
2066
|
+
async function pair(target, options = {}) {
|
|
2067
|
+
const context = options.context ?? new Context(target.id ?? target.address, options.identity);
|
|
2068
|
+
const control = new ControlChannel(context, target.address, target.port ?? 7e3);
|
|
2069
|
+
const interrupt = () => control.destroy();
|
|
2070
|
+
options.signal?.addEventListener("abort", interrupt, {
|
|
2071
|
+
once: true,
|
|
2072
|
+
signal: control.signal
|
|
2073
|
+
});
|
|
2074
|
+
try {
|
|
2075
|
+
await control.connect();
|
|
2076
|
+
await fetchInfo(control, context);
|
|
2077
|
+
const setup = new PairSetup(context, rtspTransport(control, "/pair-setup", 3));
|
|
2078
|
+
await setup.start();
|
|
2079
|
+
await pinStart(control, 3);
|
|
2080
|
+
return new PinPairing(control, setup);
|
|
2081
|
+
} catch (err) {
|
|
2082
|
+
control.destroy();
|
|
2083
|
+
throw options.signal?.aborted ? new ConnectionClosedError("Pairing was interrupted.") : err;
|
|
2084
|
+
}
|
|
2085
|
+
}
|
|
2086
|
+
/** A PIN pairing whose PIN the receiver shows now. One connection, used once. */
|
|
2087
|
+
var PinPairing = class {
|
|
2088
|
+
/** Aborts when the pairing connection closes. */
|
|
2089
|
+
get signal() {
|
|
2090
|
+
return this.#control.signal;
|
|
2091
|
+
}
|
|
2092
|
+
#control;
|
|
2093
|
+
#setup;
|
|
2094
|
+
constructor(control, setup) {
|
|
2095
|
+
this.#control = control;
|
|
2096
|
+
this.#setup = setup;
|
|
2097
|
+
}
|
|
2098
|
+
/**
|
|
2099
|
+
* Runs pair-setup M1 to M6 with the PIN from the screen. A wrong PIN leaves the connection
|
|
2100
|
+
* open; call {@link close} when giving up.
|
|
2101
|
+
*
|
|
2102
|
+
* @throws {PairingError} When a step fails or the PIN is wrong.
|
|
2103
|
+
*/
|
|
2104
|
+
async pin(code) {
|
|
2105
|
+
const m1 = await this.#setup.m1();
|
|
2106
|
+
const m2 = await this.#setup.m2(m1, code);
|
|
2107
|
+
const m3 = await this.#setup.m3(m2);
|
|
2108
|
+
const m4 = await this.#setup.m4(m3);
|
|
2109
|
+
const m5 = await this.#setup.m5(m4);
|
|
2110
|
+
const credentials = await this.#setup.m6(m4, m5);
|
|
2111
|
+
if (!credentials) throw new PairingError("Pairing failed: could not obtain credentials.");
|
|
2112
|
+
return credentials;
|
|
2113
|
+
}
|
|
2114
|
+
/** Closes the pairing connection. */
|
|
2115
|
+
close() {
|
|
2116
|
+
this.#control.destroy();
|
|
2117
|
+
}
|
|
2118
|
+
};
|
|
2119
|
+
|
|
2120
|
+
//#endregion
|
|
2121
|
+
//#region src/playback/playback.ts
|
|
2122
|
+
/** Maximum number of attempts of `POST /play` while it answers 500. */
|
|
2123
|
+
const PLAY_RETRIES = 3;
|
|
2124
|
+
/** Interval in milliseconds between `/playback-info` polls. */
|
|
2125
|
+
const PLAYBACK_POLL_INTERVAL = 1e3;
|
|
2126
|
+
/** Number of consecutive polls without a duration before playback counts as ended. */
|
|
2127
|
+
const PLAYBACK_IDLE_THRESHOLD = 5;
|
|
2128
|
+
/** CMTime with all fields zero, which the receiver reads as "no end time". */
|
|
2129
|
+
const NO_END_TIME = {
|
|
2130
|
+
flags: 0,
|
|
2131
|
+
value: 0,
|
|
2132
|
+
epoch: 0,
|
|
2133
|
+
timescale: 0
|
|
2134
|
+
};
|
|
2135
|
+
/**
|
|
2136
|
+
* A URL the receiver fetches and plays itself, on a connection of its own so remote control stays
|
|
2137
|
+
* untouched. Through the play queue when the receiver has one, otherwise `/play`.
|
|
2138
|
+
* Owns its session: {@link stop} and the end of playback close it.
|
|
2139
|
+
*/
|
|
2140
|
+
var UrlPlayback = class UrlPlayback {
|
|
2141
|
+
/**
|
|
2142
|
+
* Resolves when playback ends, then closes the connection; rejects with a {@link PlaybackError}
|
|
2143
|
+
* when the receiver reports a playback error. A play queue session follows its events.
|
|
2144
|
+
* Otherwise the first read starts polling `/playback-info`: playback starts with the first
|
|
2145
|
+
* answer that has a duration and ends after {@link PLAYBACK_IDLE_THRESHOLD} answers in a row
|
|
2146
|
+
* without one, or when the receiver stops answering. A consumer that never reads this sends
|
|
2147
|
+
* no polls.
|
|
2148
|
+
*/
|
|
2149
|
+
get ended() {
|
|
2150
|
+
this.#ended ??= this.#waitForEnd();
|
|
2151
|
+
return this.#ended;
|
|
2152
|
+
}
|
|
2153
|
+
/** Aborts when the playback's connection closes. */
|
|
2154
|
+
get signal() {
|
|
2155
|
+
return this.#session.signal;
|
|
2156
|
+
}
|
|
2157
|
+
#channels;
|
|
2158
|
+
#session;
|
|
2159
|
+
#playQueue;
|
|
2160
|
+
#ended;
|
|
2161
|
+
constructor(channels, playQueue) {
|
|
2162
|
+
this.#channels = channels;
|
|
2163
|
+
this.#session = channels.session;
|
|
2164
|
+
this.#playQueue = playQueue;
|
|
2165
|
+
this.#session.signal.addEventListener("abort", () => {
|
|
2166
|
+
if (this.#session.closeReason === "local") this.#playQueue?.close();
|
|
2167
|
+
}, { once: true });
|
|
2168
|
+
}
|
|
2169
|
+
/**
|
|
2170
|
+
* Starts playback after RECORD: through the play queue when the receiver supports it, else
|
|
2171
|
+
* with `POST /play`, retrying 500 responses.
|
|
2172
|
+
*
|
|
2173
|
+
* @throws {PlaybackError} When playback fails, or the play queue fails on a receiver without `/play`.
|
|
2174
|
+
*/
|
|
2175
|
+
static async start(channels, url, position) {
|
|
2176
|
+
const playQueue = await startPlayQueue(channels, url, position);
|
|
2177
|
+
if (playQueue) return new UrlPlayback(channels, playQueue);
|
|
2178
|
+
await playLegacy(channels, url, position);
|
|
2179
|
+
return new UrlPlayback(channels);
|
|
2180
|
+
}
|
|
2181
|
+
/**
|
|
2182
|
+
* The current playback info. A play queue session answers from its last `playing` event,
|
|
2183
|
+
* because `GET /playback-info` fails there (500 on tvOS 26, 403 on tvOS 27).
|
|
2184
|
+
*
|
|
2185
|
+
* @returns Empty when nothing plays, null when the receiver did not answer.
|
|
2186
|
+
*/
|
|
2187
|
+
async getPlaybackInfo() {
|
|
2188
|
+
if (this.#playQueue) return this.#playQueue.lastInfo;
|
|
2189
|
+
try {
|
|
2190
|
+
const response = await this.#channels.control.get("/playback-info");
|
|
2191
|
+
if (!response.ok) return null;
|
|
2192
|
+
const body = await response.arrayBuffer();
|
|
2193
|
+
if (body.byteLength === 0) return {};
|
|
2194
|
+
return Plist.parse(body);
|
|
2195
|
+
} catch {
|
|
2196
|
+
return null;
|
|
2197
|
+
}
|
|
2198
|
+
}
|
|
2199
|
+
async #waitForEnd() {
|
|
2200
|
+
try {
|
|
2201
|
+
if (this.#playQueue) await this.#playQueue.waitForEnd();
|
|
2202
|
+
else await this.#poll();
|
|
2203
|
+
} finally {
|
|
2204
|
+
this.#session.close();
|
|
2205
|
+
}
|
|
2206
|
+
}
|
|
2207
|
+
/** Stops playback and closes the connection, without TEARDOWN. */
|
|
2208
|
+
stop() {
|
|
2209
|
+
this.#playQueue?.close();
|
|
2210
|
+
this.#session.close();
|
|
2211
|
+
}
|
|
2212
|
+
async #poll() {
|
|
2213
|
+
const logger = this.#channels.context.logger;
|
|
2214
|
+
let playbackStarted = false;
|
|
2215
|
+
let idleCount = 0;
|
|
2216
|
+
while (true) {
|
|
2217
|
+
const info = await this.getPlaybackInfo();
|
|
2218
|
+
if (!info) {
|
|
2219
|
+
logger.debug("[protocol]", "Connection lost, assuming playback stopped.");
|
|
2220
|
+
return;
|
|
2221
|
+
}
|
|
2222
|
+
if (info.error) throw new PlaybackError(`Playback error: ${info.error.code} (${info.error.domain})`);
|
|
2223
|
+
if (info.duration !== void 0) {
|
|
2224
|
+
playbackStarted = true;
|
|
2225
|
+
idleCount = 0;
|
|
2226
|
+
} else if (playbackStarted) {
|
|
2227
|
+
idleCount++;
|
|
2228
|
+
if (idleCount >= PLAYBACK_IDLE_THRESHOLD) {
|
|
2229
|
+
logger.debug("[protocol]", "Playback ended.");
|
|
2230
|
+
return;
|
|
2231
|
+
}
|
|
2232
|
+
}
|
|
2233
|
+
await waitFor(PLAYBACK_POLL_INTERVAL);
|
|
2234
|
+
}
|
|
2235
|
+
}
|
|
2236
|
+
};
|
|
2237
|
+
/**
|
|
2238
|
+
* Queues the URL through the play queue when the receiver advertises one.
|
|
2239
|
+
*
|
|
2240
|
+
* @returns The running play queue, or undefined when `/play` should be used instead.
|
|
2241
|
+
* @throws {PlaybackError} When the play queue fails on a receiver that has no `/play`.
|
|
2242
|
+
*/
|
|
2243
|
+
async function startPlayQueue(channels, url, position) {
|
|
2244
|
+
const { context, features } = channels;
|
|
2245
|
+
const playQueue = hasFeatureFlag(features, AirPlayFeatureFlags.SupportsAirPlayVideoPlayQueue);
|
|
2246
|
+
const videoV2 = hasFeatureFlag(features, AirPlayFeatureFlags.SupportsAirPlayVideoV2);
|
|
2247
|
+
const videoV1 = hasFeatureFlag(features, AirPlayFeatureFlags.SupportsAirPlayVideoV1);
|
|
2248
|
+
const bits = `VideoPlayQueue=${playQueue}, VideoV2=${videoV2}, VideoV1=${videoV1}`;
|
|
2249
|
+
if (!playQueue && !videoV2) {
|
|
2250
|
+
context.logger.info("[protocol]", `playUrl via /play (${bits})`);
|
|
2251
|
+
return;
|
|
2252
|
+
}
|
|
2253
|
+
context.logger.info("[protocol]", `playUrl via play queue (${bits})`);
|
|
2254
|
+
const session = new PlayQueueSession(context, channels.control, channels.events, channels.sessionUUID, channels.senderMac);
|
|
2255
|
+
try {
|
|
2256
|
+
if (await session.setup() && await session.start(url, position)) return session;
|
|
2257
|
+
} catch (err) {
|
|
2258
|
+
session.close();
|
|
2259
|
+
throw err;
|
|
2260
|
+
}
|
|
2261
|
+
session.close();
|
|
2262
|
+
if (videoV2 && !videoV1) throw new PlaybackError("The play queue failed and this receiver does not support /play.");
|
|
2263
|
+
context.logger.warn("[protocol]", `Play queue failed, falling back to /play (${bits})`);
|
|
2264
|
+
}
|
|
2265
|
+
/** `POST /play`, retrying 500 responses because the receiver may need time to prepare, then the playback properties. */
|
|
2266
|
+
async function playLegacy(channels, url, position) {
|
|
2267
|
+
const { context, control } = channels;
|
|
2268
|
+
const identity = resolveIdentity(context);
|
|
2269
|
+
let lastStatus = 0;
|
|
2270
|
+
for (let retry = 0; retry < PLAY_RETRIES; retry++) {
|
|
2271
|
+
lastStatus = (await control.post("/play", {
|
|
2272
|
+
"Content-Location": url,
|
|
2273
|
+
"Start-Position-Seconds": position,
|
|
2274
|
+
uuid: channels.sessionUUID.toUpperCase(),
|
|
2275
|
+
streamType: 1,
|
|
2276
|
+
mediaType: "file",
|
|
2277
|
+
volume: 1,
|
|
2278
|
+
rate: 1,
|
|
2279
|
+
clientBundleID: "com.basmilius.apple-protocols",
|
|
2280
|
+
clientProcName: identity.name,
|
|
2281
|
+
osBuildVersion: identity.osBuildVersion,
|
|
2282
|
+
model: identity.model,
|
|
2283
|
+
SenderMACAddress: getMacAddress().toUpperCase()
|
|
2284
|
+
})).status;
|
|
2285
|
+
context.logger.info("[protocol]", `play_url response: ${lastStatus} (attempt ${retry + 1}/${PLAY_RETRIES})`);
|
|
2286
|
+
if (lastStatus === 200) break;
|
|
2287
|
+
if (lastStatus === 500) {
|
|
2288
|
+
context.logger.warn("[protocol]", "play_url returned 500, retrying...");
|
|
2289
|
+
await waitFor(1e3);
|
|
2290
|
+
continue;
|
|
2291
|
+
}
|
|
2292
|
+
if (lastStatus >= 400) throw new PlaybackError(`Failed to play URL: ${lastStatus}`);
|
|
2293
|
+
}
|
|
2294
|
+
if (lastStatus !== 200) throw new PlaybackError(`Failed to play URL after ${PLAY_RETRIES} retries: ${lastStatus}`);
|
|
2295
|
+
await control.put("/setProperty?isInterestedInDateRange", { value: true });
|
|
2296
|
+
await control.put("/setProperty?actionAtItemEnd", { value: 0 });
|
|
2297
|
+
await control.post("/rate?value=1.000000");
|
|
2298
|
+
await control.put("/setProperty?forwardEndTime", { value: NO_END_TIME });
|
|
2299
|
+
await control.put("/setProperty?reverseEndTime", { value: NO_END_TIME });
|
|
2300
|
+
}
|
|
2301
|
+
|
|
2302
|
+
//#endregion
|
|
2303
|
+
//#region src/timing/ntp.ts
|
|
2304
|
+
/**
|
|
2305
|
+
* NTP timing server for AirPlay audio synchronization.
|
|
2306
|
+
*
|
|
2307
|
+
* During audio streaming, Apple devices send NTP timing requests to sync their
|
|
2308
|
+
* clock with the controller. The server answers with the current wall-clock
|
|
2309
|
+
* time, from which the device derives the playback offset for multiroom audio.
|
|
2310
|
+
* It uses `Date.now()`, not the monotonic `process.hrtime`, because NTP
|
|
2311
|
+
* timestamps are anchored to the Unix epoch.
|
|
2312
|
+
*/
|
|
2313
|
+
var TimingServer = class {
|
|
2314
|
+
/** The UDP port the timing server is listening on, or 0 if not yet bound. */
|
|
2315
|
+
get port() {
|
|
2316
|
+
return this.#port;
|
|
2317
|
+
}
|
|
2318
|
+
#logger;
|
|
2319
|
+
#socket;
|
|
2320
|
+
#port = 0;
|
|
2321
|
+
/** @param options.logger - Receives the errors of the server; a `timing-server` logger when omitted. */
|
|
2322
|
+
constructor(options = {}) {
|
|
2323
|
+
this.#logger = options.logger ?? new Logger("timing-server");
|
|
2324
|
+
this.#socket = createSocket("udp4");
|
|
2325
|
+
this.onError = this.onError.bind(this);
|
|
2326
|
+
this.onMessage = this.onMessage.bind(this);
|
|
2327
|
+
this.#socket.on("error", this.onError);
|
|
2328
|
+
this.#socket.on("message", this.onMessage);
|
|
2329
|
+
}
|
|
2330
|
+
/** Closes the UDP socket and resets the port. */
|
|
2331
|
+
close() {
|
|
2332
|
+
try {
|
|
2333
|
+
this.#socket.close();
|
|
2334
|
+
} catch {}
|
|
2335
|
+
this.#port = 0;
|
|
2336
|
+
}
|
|
2337
|
+
/**
|
|
2338
|
+
* Binds the UDP socket to a random free port and starts answering NTP
|
|
2339
|
+
* timing requests. Rejects when the socket fails to bind.
|
|
2340
|
+
*/
|
|
2341
|
+
listen() {
|
|
2342
|
+
return new Promise((resolve, reject) => {
|
|
2343
|
+
const onError = (err) => {
|
|
2344
|
+
this.#socket.removeListener("listening", onListening);
|
|
2345
|
+
reject(err);
|
|
2346
|
+
};
|
|
2347
|
+
const onListening = () => {
|
|
2348
|
+
this.#socket.removeListener("error", onError);
|
|
2349
|
+
this.#onListening();
|
|
2350
|
+
resolve();
|
|
2351
|
+
};
|
|
2352
|
+
this.#socket.once("error", onError);
|
|
2353
|
+
this.#socket.once("listening", onListening);
|
|
2354
|
+
this.#socket.bind(0);
|
|
2355
|
+
});
|
|
2356
|
+
}
|
|
2357
|
+
onError(err) {
|
|
2358
|
+
this.#logger.error("Timing server error", err);
|
|
2359
|
+
}
|
|
2360
|
+
#onListening() {
|
|
2361
|
+
const { port } = this.#socket.address();
|
|
2362
|
+
this.#port = port;
|
|
2363
|
+
}
|
|
2364
|
+
/**
|
|
2365
|
+
* Answers an NTP timing request. The reference timestamp echoes the send
|
|
2366
|
+
* time of the request; receive and send time are both now.
|
|
2367
|
+
*/
|
|
2368
|
+
onMessage(data, info) {
|
|
2369
|
+
try {
|
|
2370
|
+
const request = NTP.decode(data);
|
|
2371
|
+
const ntp = NTP.now();
|
|
2372
|
+
const [receivedSeconds, receivedFraction] = NTP.parts(ntp);
|
|
2373
|
+
this.#logger.info(`Timing server ntp=${ntp} receivedSeconds=${receivedSeconds} receivedFraction=${receivedFraction}`);
|
|
2374
|
+
const response = NTP.encode({
|
|
2375
|
+
proto: request.proto,
|
|
2376
|
+
type: 211,
|
|
2377
|
+
seqno: request.seqno,
|
|
2378
|
+
padding: 0,
|
|
2379
|
+
reftime_sec: request.sendtime_sec,
|
|
2380
|
+
reftime_frac: request.sendtime_frac,
|
|
2381
|
+
recvtime_sec: receivedSeconds,
|
|
2382
|
+
recvtime_frac: receivedFraction,
|
|
2383
|
+
sendtime_sec: receivedSeconds,
|
|
2384
|
+
sendtime_frac: receivedFraction
|
|
2385
|
+
});
|
|
2386
|
+
this.#socket.send(response, info.port, info.address, (err) => {
|
|
2387
|
+
if (!err) return;
|
|
2388
|
+
this.#logger.warn(`Timing server failed to send response to ${info.address}:${info.port}`, err);
|
|
2389
|
+
});
|
|
2390
|
+
} catch (err) {
|
|
2391
|
+
this.#logger.warn(`Timing server received malformed packet (${data.length} bytes) from ${info.address}:${info.port}`, err);
|
|
2392
|
+
}
|
|
2393
|
+
}
|
|
2394
|
+
};
|
|
2395
|
+
|
|
2396
|
+
//#endregion
|
|
2397
|
+
//#region src/timing/ptp/clockIdentity.ts
|
|
2398
|
+
/**
|
|
2399
|
+
* Generates an 8-byte EUI-64 clock identity from this machine's MAC-48 address,
|
|
2400
|
+
* for PTPv2 `sourcePortIdentity.clockIdentity`. The bytes `0xFF 0xFE` go
|
|
2401
|
+
* between the OUI (first three octets) and the NIC-specific part.
|
|
2402
|
+
*/
|
|
2403
|
+
function generateClockIdentity() {
|
|
2404
|
+
const mac = Buffer.from(getMacAddress().replace(/:/g, ""), "hex");
|
|
2405
|
+
const id = Buffer.alloc(8);
|
|
2406
|
+
mac.copy(id, 0, 0, 3);
|
|
2407
|
+
id[3] = 255;
|
|
2408
|
+
id[4] = 254;
|
|
2409
|
+
mac.copy(id, 5, 3, 6);
|
|
2410
|
+
return id;
|
|
2411
|
+
}
|
|
2412
|
+
|
|
2413
|
+
//#endregion
|
|
2414
|
+
//#region src/timing/ptp/header.ts
|
|
2415
|
+
/** Byte length of a PTPv2 common message header. */
|
|
2416
|
+
const PTP_HEADER_LENGTH = 34;
|
|
2417
|
+
/** PTPv2 protocol version written into every header. */
|
|
2418
|
+
const PTP_VERSION = 2;
|
|
2419
|
+
/**
|
|
2420
|
+
* Encodes the fields of a PTPv2 common header into a 34-byte buffer.
|
|
2421
|
+
* Reserved bytes stay zero, as macOS and iOS senders write them.
|
|
2422
|
+
*/
|
|
2423
|
+
function encodeHeader(fields) {
|
|
2424
|
+
const buf = Buffer.alloc(34);
|
|
2425
|
+
buf[0] = fields.messageType & 15 | (fields.transportSpecific & 15) << 4;
|
|
2426
|
+
buf[1] = 2;
|
|
2427
|
+
buf.writeUInt16BE(fields.messageLength, 2);
|
|
2428
|
+
buf[4] = fields.domainNumber;
|
|
2429
|
+
buf.writeUInt16BE(fields.flags, 6);
|
|
2430
|
+
buf.writeBigInt64BE(fields.correctionField, 8);
|
|
2431
|
+
fields.sourcePortIdentity.copy(buf, 20, 0, 10);
|
|
2432
|
+
buf.writeUInt16BE(fields.sequenceId, 30);
|
|
2433
|
+
buf[32] = fields.controlField;
|
|
2434
|
+
buf.writeInt8(fields.logMessageInterval, 33);
|
|
2435
|
+
return buf;
|
|
2436
|
+
}
|
|
2437
|
+
/**
|
|
2438
|
+
* Decodes a PTPv2 common header from the first 34 bytes of the buffer.
|
|
2439
|
+
*/
|
|
2440
|
+
function decodeHeader(buf) {
|
|
2441
|
+
return {
|
|
2442
|
+
messageType: buf[0] & 15,
|
|
2443
|
+
transportSpecific: buf[0] >> 4 & 15,
|
|
2444
|
+
messageLength: buf.readUInt16BE(2),
|
|
2445
|
+
domainNumber: buf[4],
|
|
2446
|
+
flags: buf.readUInt16BE(6),
|
|
2447
|
+
correctionField: buf.readBigInt64BE(8),
|
|
2448
|
+
sourcePortIdentity: Buffer.from(buf.subarray(20, 30)),
|
|
2449
|
+
sequenceId: buf.readUInt16BE(30),
|
|
2450
|
+
controlField: buf[32],
|
|
2451
|
+
logMessageInterval: buf.readInt8(33)
|
|
2452
|
+
};
|
|
2453
|
+
}
|
|
2454
|
+
|
|
2455
|
+
//#endregion
|
|
2456
|
+
//#region src/timing/ptp/timestamp.ts
|
|
2457
|
+
/**
|
|
2458
|
+
* Captures the current wall-clock time as a {@link PtpTimestamp}.
|
|
2459
|
+
*
|
|
2460
|
+
* Uses {@link Date.now}, not the monotonic {@link process.hrtime.bigint},
|
|
2461
|
+
* because PTP timestamps are anchored to the Unix epoch, as with {@link NTP.now}.
|
|
2462
|
+
*/
|
|
2463
|
+
function ptpNow() {
|
|
2464
|
+
const nowMs = BigInt(Date.now());
|
|
2465
|
+
const seconds = nowMs / 1000n;
|
|
2466
|
+
return {
|
|
2467
|
+
seconds,
|
|
2468
|
+
nanoseconds: Number((nowMs - seconds * 1000n) * 1000000n)
|
|
2469
|
+
};
|
|
2470
|
+
}
|
|
2471
|
+
/**
|
|
2472
|
+
* Encodes a {@link PtpTimestamp} into the 10-byte PTP wire format:
|
|
2473
|
+
* 48-bit seconds (big-endian) followed by 32-bit nanoseconds (big-endian).
|
|
2474
|
+
*/
|
|
2475
|
+
function encodeTimestamp(ts) {
|
|
2476
|
+
const buf = Buffer.alloc(10);
|
|
2477
|
+
const high = Number(ts.seconds >> 32n & 65535n);
|
|
2478
|
+
const low = Number(ts.seconds & 4294967295n);
|
|
2479
|
+
buf.writeUInt16BE(high, 0);
|
|
2480
|
+
buf.writeUInt32BE(low, 2);
|
|
2481
|
+
buf.writeUInt32BE(ts.nanoseconds, 6);
|
|
2482
|
+
return buf;
|
|
2483
|
+
}
|
|
2484
|
+
/**
|
|
2485
|
+
* Decodes a 10-byte PTP timestamp from the buffer at the given offset.
|
|
2486
|
+
*/
|
|
2487
|
+
function decodeTimestamp(buf, offset) {
|
|
2488
|
+
const high = BigInt(buf.readUInt16BE(offset));
|
|
2489
|
+
const low = BigInt(buf.readUInt32BE(offset + 2));
|
|
2490
|
+
const nanoseconds = buf.readUInt32BE(offset + 6);
|
|
2491
|
+
return {
|
|
2492
|
+
seconds: high << 32n | low,
|
|
2493
|
+
nanoseconds
|
|
2494
|
+
};
|
|
2495
|
+
}
|
|
2496
|
+
|
|
2497
|
+
//#endregion
|
|
2498
|
+
//#region src/timing/ptp/tlv.ts
|
|
2499
|
+
/** IEEE 1588-2008 standard TLV types used on the wire. */
|
|
2500
|
+
const TlvType = {
|
|
2501
|
+
Management: 1,
|
|
2502
|
+
OrganizationExtension: 3,
|
|
2503
|
+
RequestUnicastTransmission: 4,
|
|
2504
|
+
GrantUnicastTransmission: 5,
|
|
2505
|
+
CancelUnicastTransmission: 6,
|
|
2506
|
+
AcknowledgeCancelUnicastTransmission: 7
|
|
2507
|
+
};
|
|
2508
|
+
/** IEEE 802.1AS Apple-gPTP Organization ID (00:80:C2 = IEEE 802). */
|
|
2509
|
+
const IEEE_802_1_ORGANIZATION_ID = Buffer.from([
|
|
2510
|
+
0,
|
|
2511
|
+
128,
|
|
2512
|
+
194
|
|
2513
|
+
]);
|
|
2514
|
+
/** Organization sub-type identifier for the 802.1AS FollowUpInformation TLV. */
|
|
2515
|
+
const FOLLOW_UP_INFORMATION_SUBTYPE = 1;
|
|
2516
|
+
/** Organization sub-type identifier for the 802.1AS MessageIntervalRequest TLV. */
|
|
2517
|
+
const MESSAGE_INTERVAL_REQUEST_SUBTYPE = 2;
|
|
2518
|
+
/**
|
|
2519
|
+
* Encodes an IEEE 802.1AS FollowUpInformation TLV (subtype 1), including the
|
|
2520
|
+
* 4-byte TLV header. It trails every FollowUp body. A sender-only role sends
|
|
2521
|
+
* zeros, as macOS senders do.
|
|
2522
|
+
*
|
|
2523
|
+
* @param cumulativeScaledRateOffset - Written as u32 BE.
|
|
2524
|
+
* @param gmTimeBaseIndicator - Written as u16 BE.
|
|
2525
|
+
* @param lastGmPhaseChange - 12 bytes of scaled nanoseconds in fixed point.
|
|
2526
|
+
* @param scaledLastGmFreqChange - Written as u32 BE.
|
|
2527
|
+
*/
|
|
2528
|
+
function encodeFollowUpInfoTlv(cumulativeScaledRateOffset = 0, gmTimeBaseIndicator = 0, lastGmPhaseChange = Buffer.alloc(12), scaledLastGmFreqChange = 0) {
|
|
2529
|
+
if (lastGmPhaseChange.length !== 12) throw new Error("lastGmPhaseChange must be exactly 12 bytes.");
|
|
2530
|
+
const payload = Buffer.alloc(26);
|
|
2531
|
+
IEEE_802_1_ORGANIZATION_ID.copy(payload, 0);
|
|
2532
|
+
payload[3] = FOLLOW_UP_INFORMATION_SUBTYPE;
|
|
2533
|
+
payload.writeUInt32BE(cumulativeScaledRateOffset, 4);
|
|
2534
|
+
payload.writeUInt16BE(gmTimeBaseIndicator, 8);
|
|
2535
|
+
lastGmPhaseChange.copy(payload, 10);
|
|
2536
|
+
payload.writeUInt32BE(scaledLastGmFreqChange, 22);
|
|
2537
|
+
const tlv = Buffer.alloc(4 + payload.length);
|
|
2538
|
+
tlv.writeUInt16BE(TlvType.OrganizationExtension, 0);
|
|
2539
|
+
tlv.writeUInt16BE(payload.length, 2);
|
|
2540
|
+
payload.copy(tlv, 4);
|
|
2541
|
+
return tlv;
|
|
2542
|
+
}
|
|
2543
|
+
/**
|
|
2544
|
+
* Encodes an IEEE 802.1AS MessageIntervalRequest TLV (subtype 2), including
|
|
2545
|
+
* the 4-byte TLV header. A Signaling message carries it to ask the peer to
|
|
2546
|
+
* adjust or stop its Sync, Announce or Pdelay messages. When we yield the
|
|
2547
|
+
* master role, all three fields are `0x7E` for a full stop.
|
|
2548
|
+
*
|
|
2549
|
+
* @param logLinkDelayInterval - Pdelay interval as signed log2 of seconds; 0x7E stops.
|
|
2550
|
+
* @param logTimeSyncInterval - Sync interval as signed log2 of seconds; 0x7E stops.
|
|
2551
|
+
* @param logAnnounceInterval - Announce interval as signed log2 of seconds; 0x7E stops.
|
|
2552
|
+
*/
|
|
2553
|
+
function encodeMessageIntervalRequestTlv(logLinkDelayInterval, logTimeSyncInterval, logAnnounceInterval) {
|
|
2554
|
+
const payload = Buffer.alloc(10);
|
|
2555
|
+
IEEE_802_1_ORGANIZATION_ID.copy(payload, 0);
|
|
2556
|
+
payload[3] = MESSAGE_INTERVAL_REQUEST_SUBTYPE;
|
|
2557
|
+
payload.writeInt8(logLinkDelayInterval, 4);
|
|
2558
|
+
payload.writeInt8(logTimeSyncInterval, 5);
|
|
2559
|
+
payload.writeInt8(logAnnounceInterval, 6);
|
|
2560
|
+
payload[7] = 0;
|
|
2561
|
+
const tlv = Buffer.alloc(4 + payload.length);
|
|
2562
|
+
tlv.writeUInt16BE(TlvType.OrganizationExtension, 0);
|
|
2563
|
+
tlv.writeUInt16BE(payload.length, 2);
|
|
2564
|
+
payload.copy(tlv, 4);
|
|
2565
|
+
return tlv;
|
|
2566
|
+
}
|
|
2567
|
+
|
|
2568
|
+
//#endregion
|
|
2569
|
+
//#region src/timing/ptp/messages.ts
|
|
2570
|
+
/**
|
|
2571
|
+
* PTPv2 control field per message type. PTPv1 receivers need it; PTPv2-only
|
|
2572
|
+
* stacks ignore it.
|
|
2573
|
+
*/
|
|
2574
|
+
const ControlField = {
|
|
2575
|
+
Sync: 0,
|
|
2576
|
+
DelayReq: 1,
|
|
2577
|
+
FollowUp: 2,
|
|
2578
|
+
DelayResp: 3,
|
|
2579
|
+
Management: 4,
|
|
2580
|
+
AllOthers: 5
|
|
2581
|
+
};
|
|
2582
|
+
/** macOS sends transportSpecific = 1 in its PTP messages. */
|
|
2583
|
+
const TRANSPORT_SPECIFIC = 1;
|
|
2584
|
+
/** Default priority1 advertised by our grandmaster. */
|
|
2585
|
+
const DEFAULT_PRIORITY_1 = 248;
|
|
2586
|
+
/** Default priority2 advertised by our grandmaster. */
|
|
2587
|
+
const DEFAULT_PRIORITY_2 = 128;
|
|
2588
|
+
/** clockClass of every PTP sender that is not a primary reference. */
|
|
2589
|
+
const DEFAULT_CLOCK_CLASS = 248;
|
|
2590
|
+
/** clockAccuracy "unknown", as macOS sends it. */
|
|
2591
|
+
const DEFAULT_CLOCK_ACCURACY = 254;
|
|
2592
|
+
/** offsetScaledLogVariance "unknown", as macOS sends it. */
|
|
2593
|
+
const DEFAULT_OFFSET_SCALED_LOG_VARIANCE = 65535;
|
|
2594
|
+
/** UTC to TAI offset in leap seconds, as macOS sends it. */
|
|
2595
|
+
const DEFAULT_CURRENT_UTC_OFFSET = 37;
|
|
2596
|
+
/** Time source "internalOscillator", which Apple claims as grandmaster. */
|
|
2597
|
+
const DEFAULT_TIME_SOURCE = 160;
|
|
2598
|
+
/** log2 of the interval in seconds, written in the header of each message type. */
|
|
2599
|
+
const LogInterval = {
|
|
2600
|
+
Sync: -3,
|
|
2601
|
+
Announce: 0,
|
|
2602
|
+
FollowUp: 0,
|
|
2603
|
+
DelayReq: 127,
|
|
2604
|
+
DelayResp: 127,
|
|
2605
|
+
Signaling: 127
|
|
2606
|
+
};
|
|
2607
|
+
function buildHeader(messageType, clockIdentity, sequenceId, messageLength, controlField, logMessageInterval, flags = 0) {
|
|
2608
|
+
const sourcePortIdentity = Buffer.alloc(10);
|
|
2609
|
+
clockIdentity.copy(sourcePortIdentity, 0, 0, 8);
|
|
2610
|
+
sourcePortIdentity.writeUInt16BE(1, 8);
|
|
2611
|
+
return encodeHeader({
|
|
2612
|
+
messageType,
|
|
2613
|
+
transportSpecific: TRANSPORT_SPECIFIC,
|
|
2614
|
+
messageLength,
|
|
2615
|
+
domainNumber: 0,
|
|
2616
|
+
flags,
|
|
2617
|
+
correctionField: 0n,
|
|
2618
|
+
sourcePortIdentity,
|
|
2619
|
+
sequenceId,
|
|
2620
|
+
controlField,
|
|
2621
|
+
logMessageInterval
|
|
2622
|
+
});
|
|
2623
|
+
}
|
|
2624
|
+
/**
|
|
2625
|
+
* Encodes a two-step Sync message of 44 bytes (34 header, 10 timestamp). The
|
|
2626
|
+
* precise origin timestamp follows in a FollowUp.
|
|
2627
|
+
*
|
|
2628
|
+
* @param clockIdentity - Our 8-byte EUI-64 clock identity.
|
|
2629
|
+
*/
|
|
2630
|
+
function encodeSync(sequenceId, clockIdentity) {
|
|
2631
|
+
const header = buildHeader(0, clockIdentity, sequenceId, 34 + 10, ControlField.Sync, LogInterval.Sync, 520);
|
|
2632
|
+
const zeroTs = encodeTimestamp({
|
|
2633
|
+
seconds: 0n,
|
|
2634
|
+
nanoseconds: 0
|
|
2635
|
+
});
|
|
2636
|
+
return Buffer.concat([header, zeroTs]);
|
|
2637
|
+
}
|
|
2638
|
+
/**
|
|
2639
|
+
* Encodes a FollowUp message carrying the precise origin timestamp for the
|
|
2640
|
+
* preceding Sync: 44 bytes plus a 30-byte FollowUpInformation TLV.
|
|
2641
|
+
*
|
|
2642
|
+
* @param sequenceId - Must match the preceding Sync.
|
|
2643
|
+
* @param clockIdentity - Our 8-byte EUI-64 clock identity.
|
|
2644
|
+
* @param preciseOriginTimestamp - When the Sync was sent.
|
|
2645
|
+
*/
|
|
2646
|
+
function encodeFollowUp(sequenceId, clockIdentity, preciseOriginTimestamp) {
|
|
2647
|
+
const timestamp = encodeTimestamp(preciseOriginTimestamp);
|
|
2648
|
+
const tlv = encodeFollowUpInfoTlv();
|
|
2649
|
+
const header = buildHeader(8, clockIdentity, sequenceId, 34 + timestamp.length + tlv.length, ControlField.FollowUp, LogInterval.FollowUp, 8);
|
|
2650
|
+
return Buffer.concat([
|
|
2651
|
+
header,
|
|
2652
|
+
timestamp,
|
|
2653
|
+
tlv
|
|
2654
|
+
]);
|
|
2655
|
+
}
|
|
2656
|
+
/**
|
|
2657
|
+
* Encodes a 64-byte Announce message advertising our grandmaster role.
|
|
2658
|
+
*
|
|
2659
|
+
* @param clockIdentity - Our 8-byte EUI-64 clock identity, also the grandmasterIdentity.
|
|
2660
|
+
* @param priority1 - Grandmaster priority1; the default 248 is above "slave-only" and below the 246 of an Apple TV.
|
|
2661
|
+
*/
|
|
2662
|
+
function encodeAnnounce(sequenceId, clockIdentity, priority1 = 248, priority2 = 128) {
|
|
2663
|
+
const header = buildHeader(11, clockIdentity, sequenceId, 34 + 10 + 20, ControlField.AllOthers, LogInterval.Announce, 8);
|
|
2664
|
+
const zeroTs = encodeTimestamp({
|
|
2665
|
+
seconds: 0n,
|
|
2666
|
+
nanoseconds: 0
|
|
2667
|
+
});
|
|
2668
|
+
const body = Buffer.alloc(20);
|
|
2669
|
+
body.writeInt16BE(DEFAULT_CURRENT_UTC_OFFSET, 0);
|
|
2670
|
+
body[3] = priority1;
|
|
2671
|
+
body[4] = DEFAULT_CLOCK_CLASS;
|
|
2672
|
+
body[5] = DEFAULT_CLOCK_ACCURACY;
|
|
2673
|
+
body.writeUInt16BE(DEFAULT_OFFSET_SCALED_LOG_VARIANCE, 6);
|
|
2674
|
+
body[8] = priority2;
|
|
2675
|
+
clockIdentity.copy(body, 9, 0, 8);
|
|
2676
|
+
body.writeUInt16BE(0, 17);
|
|
2677
|
+
body[19] = DEFAULT_TIME_SOURCE;
|
|
2678
|
+
return Buffer.concat([
|
|
2679
|
+
header,
|
|
2680
|
+
zeroTs,
|
|
2681
|
+
body
|
|
2682
|
+
]);
|
|
2683
|
+
}
|
|
2684
|
+
/**
|
|
2685
|
+
* Encodes a 54-byte DelayResp message that answers a DelayReq: 34 header,
|
|
2686
|
+
* 10 receiveTimestamp and 10 requestingPortIdentity.
|
|
2687
|
+
*
|
|
2688
|
+
* @param sequenceId - Copied from the DelayReq.
|
|
2689
|
+
* @param clockIdentity - Our 8-byte EUI-64 clock identity.
|
|
2690
|
+
* @param receiveTimestamp - When we received the DelayReq.
|
|
2691
|
+
* @param requestingPortIdentity - The 10-byte source port identity of the DelayReq.
|
|
2692
|
+
*/
|
|
2693
|
+
function encodeDelayResp(sequenceId, clockIdentity, receiveTimestamp, requestingPortIdentity) {
|
|
2694
|
+
const timestamp = encodeTimestamp(receiveTimestamp);
|
|
2695
|
+
const header = buildHeader(9, clockIdentity, sequenceId, 34 + timestamp.length + 10, ControlField.DelayResp, LogInterval.DelayResp, 8);
|
|
2696
|
+
const requester = Buffer.alloc(10);
|
|
2697
|
+
requestingPortIdentity.copy(requester, 0, 0, 10);
|
|
2698
|
+
return Buffer.concat([
|
|
2699
|
+
header,
|
|
2700
|
+
timestamp,
|
|
2701
|
+
requester
|
|
2702
|
+
]);
|
|
2703
|
+
}
|
|
2704
|
+
/**
|
|
2705
|
+
* Encodes a Signaling message that asks the peer to stop its Sync, Announce
|
|
2706
|
+
* and Pdelay messages, with 0x7E in all three fields of a MessageIntervalRequest
|
|
2707
|
+
* TLV. We send it when we yield the master role to a peer with a higher priority.
|
|
2708
|
+
*
|
|
2709
|
+
* @param clockIdentity - Our 8-byte EUI-64 clock identity.
|
|
2710
|
+
* @param targetPortIdentity - The 10-byte port identity of the peer.
|
|
2711
|
+
*/
|
|
2712
|
+
function encodeSignalingStop(sequenceId, clockIdentity, targetPortIdentity) {
|
|
2713
|
+
const tlv = encodeMessageIntervalRequestTlv(126, 126, 126);
|
|
2714
|
+
const target = Buffer.alloc(10);
|
|
2715
|
+
targetPortIdentity.copy(target, 0, 0, 10);
|
|
2716
|
+
const header = buildHeader(12, clockIdentity, sequenceId, 34 + target.length + tlv.length, ControlField.AllOthers, LogInterval.Signaling, 8);
|
|
2717
|
+
return Buffer.concat([
|
|
2718
|
+
header,
|
|
2719
|
+
target,
|
|
2720
|
+
tlv
|
|
2721
|
+
]);
|
|
2722
|
+
}
|
|
2723
|
+
/**
|
|
2724
|
+
* Decodes a PTP message from a raw UDP packet. Only the types in
|
|
2725
|
+
* {@link PtpMessageType} decode.
|
|
2726
|
+
*
|
|
2727
|
+
* @returns The decoded message, or null for a short buffer or an unknown type.
|
|
2728
|
+
*/
|
|
2729
|
+
function decodeMessage(buf) {
|
|
2730
|
+
if (buf.length < 34) return null;
|
|
2731
|
+
const header = decodeHeader(buf);
|
|
2732
|
+
const offset = 34;
|
|
2733
|
+
switch (header.messageType) {
|
|
2734
|
+
case 0:
|
|
2735
|
+
if (buf.length < offset + 10) return null;
|
|
2736
|
+
return {
|
|
2737
|
+
type: 0,
|
|
2738
|
+
header,
|
|
2739
|
+
body: { originTimestamp: decodeTimestamp(buf, offset) }
|
|
2740
|
+
};
|
|
2741
|
+
case 1:
|
|
2742
|
+
if (buf.length < offset + 10) return null;
|
|
2743
|
+
return {
|
|
2744
|
+
type: 1,
|
|
2745
|
+
header,
|
|
2746
|
+
body: { originTimestamp: decodeTimestamp(buf, offset) }
|
|
2747
|
+
};
|
|
2748
|
+
case 8:
|
|
2749
|
+
if (buf.length < offset + 10) return null;
|
|
2750
|
+
return {
|
|
2751
|
+
type: 8,
|
|
2752
|
+
header,
|
|
2753
|
+
body: { preciseOriginTimestamp: decodeTimestamp(buf, offset) }
|
|
2754
|
+
};
|
|
2755
|
+
case 9:
|
|
2756
|
+
if (buf.length < offset + 10 + 10) return null;
|
|
2757
|
+
return {
|
|
2758
|
+
type: 9,
|
|
2759
|
+
header,
|
|
2760
|
+
body: {
|
|
2761
|
+
receiveTimestamp: decodeTimestamp(buf, offset),
|
|
2762
|
+
requestingPortIdentity: Buffer.from(buf.subarray(offset + 10, offset + 20))
|
|
2763
|
+
}
|
|
2764
|
+
};
|
|
2765
|
+
case 11: {
|
|
2766
|
+
if (buf.length < offset + 10 + 20) return null;
|
|
2767
|
+
const originTimestamp = decodeTimestamp(buf, offset);
|
|
2768
|
+
const bodyOffset = offset + 10;
|
|
2769
|
+
const currentUtcOffset = buf.readInt16BE(bodyOffset);
|
|
2770
|
+
const grandmasterPriority1 = buf[bodyOffset + 3];
|
|
2771
|
+
const clockClass = buf[bodyOffset + 4];
|
|
2772
|
+
const clockAccuracy = buf[bodyOffset + 5];
|
|
2773
|
+
const offsetScaledLogVariance = buf.readUInt16BE(bodyOffset + 6);
|
|
2774
|
+
const grandmasterPriority2 = buf[bodyOffset + 8];
|
|
2775
|
+
const grandmasterIdentity = Buffer.from(buf.subarray(bodyOffset + 9, bodyOffset + 17));
|
|
2776
|
+
const stepsRemoved = buf.readUInt16BE(bodyOffset + 17);
|
|
2777
|
+
const timeSource = buf[bodyOffset + 19];
|
|
2778
|
+
return {
|
|
2779
|
+
type: 11,
|
|
2780
|
+
header,
|
|
2781
|
+
body: {
|
|
2782
|
+
originTimestamp,
|
|
2783
|
+
currentUtcOffset,
|
|
2784
|
+
grandmasterPriority1,
|
|
2785
|
+
grandmasterClockQuality: {
|
|
2786
|
+
clockClass,
|
|
2787
|
+
clockAccuracy,
|
|
2788
|
+
offsetScaledLogVariance
|
|
2789
|
+
},
|
|
2790
|
+
grandmasterPriority2,
|
|
2791
|
+
grandmasterIdentity,
|
|
2792
|
+
stepsRemoved,
|
|
2793
|
+
timeSource
|
|
2794
|
+
}
|
|
2795
|
+
};
|
|
2796
|
+
}
|
|
2797
|
+
case 12:
|
|
2798
|
+
if (buf.length < offset + 10) return null;
|
|
2799
|
+
return {
|
|
2800
|
+
type: 12,
|
|
2801
|
+
header,
|
|
2802
|
+
body: {
|
|
2803
|
+
targetPortIdentity: Buffer.from(buf.subarray(offset, offset + 10)),
|
|
2804
|
+
tlv: Buffer.from(buf.subarray(offset + 10))
|
|
2805
|
+
}
|
|
2806
|
+
};
|
|
2807
|
+
default: return null;
|
|
2808
|
+
}
|
|
2809
|
+
}
|
|
2810
|
+
|
|
2811
|
+
//#endregion
|
|
2812
|
+
//#region src/timing/ptp/index.ts
|
|
2813
|
+
/** Interval between Sync and FollowUp pairs, logInterval -3. */
|
|
2814
|
+
const SYNC_INTERVAL_MS = 125;
|
|
2815
|
+
/** Interval between Announce messages, the macOS cadence. */
|
|
2816
|
+
const ANNOUNCE_INTERVAL_MS = 250;
|
|
2817
|
+
/** IANA event port for PTP (Sync, DelayReq). */
|
|
2818
|
+
const EVENT_PORT = 319;
|
|
2819
|
+
/** IANA general port for PTP (FollowUp, DelayResp, Announce, Signaling). */
|
|
2820
|
+
const GENERAL_PORT = 320;
|
|
2821
|
+
/**
|
|
2822
|
+
* IEEE 1588 (PTPv2) grandmaster for AirPlay 2 HomePods.
|
|
2823
|
+
*
|
|
2824
|
+
* A HomePod with `SupportsPTP` (feature bit 41) expects the sender to be the
|
|
2825
|
+
* PTP grandmaster. The master binds the two PTP UDP ports, sends Sync and
|
|
2826
|
+
* Announce at the macOS rates and answers DelayReq with DelayResp. When a peer
|
|
2827
|
+
* with a higher priority announces itself, it sends a Signaling message with
|
|
2828
|
+
* `logMessageInterval = 0x7E` and stops.
|
|
2829
|
+
*
|
|
2830
|
+
* After that yield, audio timestamps keep using the local wall clock; there
|
|
2831
|
+
* is no PTP slave. HomePods accept this as long as the Sync messages stop.
|
|
2832
|
+
*/
|
|
2833
|
+
var PtpMaster = class {
|
|
2834
|
+
/** UDP port for PTP event messages: 319, or an ephemeral port as fallback. */
|
|
2835
|
+
get eventPort() {
|
|
2836
|
+
return this.#eventPort;
|
|
2837
|
+
}
|
|
2838
|
+
/** UDP port for PTP general messages: 320, or an ephemeral port as fallback. */
|
|
2839
|
+
get generalPort() {
|
|
2840
|
+
return this.#generalPort;
|
|
2841
|
+
}
|
|
2842
|
+
get state() {
|
|
2843
|
+
return this.#state;
|
|
2844
|
+
}
|
|
2845
|
+
/** Our 8-byte EUI-64 clock identity, as advertised to peers. */
|
|
2846
|
+
get clockIdentity() {
|
|
2847
|
+
return this.#clockIdentity;
|
|
2848
|
+
}
|
|
2849
|
+
/** Number of Sync messages sent to the peer. */
|
|
2850
|
+
get syncsSent() {
|
|
2851
|
+
return this.#syncsSent;
|
|
2852
|
+
}
|
|
2853
|
+
/** Number of Announce messages sent to the peer. */
|
|
2854
|
+
get announcesSent() {
|
|
2855
|
+
return this.#announcesSent;
|
|
2856
|
+
}
|
|
2857
|
+
/** Number of DelayReq messages received from any peer. */
|
|
2858
|
+
get delayReqsReceived() {
|
|
2859
|
+
return this.#delayReqsReceived;
|
|
2860
|
+
}
|
|
2861
|
+
/** Number of DelayResp messages sent in reply. */
|
|
2862
|
+
get delayRespsSent() {
|
|
2863
|
+
return this.#delayRespsSent;
|
|
2864
|
+
}
|
|
2865
|
+
/** Number of Announce messages received from peers. */
|
|
2866
|
+
get announcesReceived() {
|
|
2867
|
+
return this.#announcesReceived;
|
|
2868
|
+
}
|
|
2869
|
+
#logger;
|
|
2870
|
+
#clockIdentity;
|
|
2871
|
+
#peerAddress;
|
|
2872
|
+
#eventSocket;
|
|
2873
|
+
#generalSocket;
|
|
2874
|
+
#eventPort = 0;
|
|
2875
|
+
#generalPort = 0;
|
|
2876
|
+
#syncSequenceId = 0;
|
|
2877
|
+
#announceSequenceId = 0;
|
|
2878
|
+
#signalingSequenceId = 0;
|
|
2879
|
+
#state = "idle";
|
|
2880
|
+
#syncTimer;
|
|
2881
|
+
#announceTimer;
|
|
2882
|
+
#syncsSent = 0;
|
|
2883
|
+
#announcesSent = 0;
|
|
2884
|
+
#delayReqsReceived = 0;
|
|
2885
|
+
#delayRespsSent = 0;
|
|
2886
|
+
#announcesReceived = 0;
|
|
2887
|
+
/**
|
|
2888
|
+
* @param peerAddress - IPv4 address of the receiver, the destination of Sync, FollowUp and Announce.
|
|
2889
|
+
*/
|
|
2890
|
+
constructor(peerAddress) {
|
|
2891
|
+
this.#logger = new Logger("ptp-master");
|
|
2892
|
+
this.#clockIdentity = generateClockIdentity();
|
|
2893
|
+
this.#peerAddress = peerAddress;
|
|
2894
|
+
this.onEventPacket = this.onEventPacket.bind(this);
|
|
2895
|
+
this.onGeneralPacket = this.onGeneralPacket.bind(this);
|
|
2896
|
+
this.onEventError = this.onEventError.bind(this);
|
|
2897
|
+
this.onGeneralError = this.onGeneralError.bind(this);
|
|
2898
|
+
}
|
|
2899
|
+
/**
|
|
2900
|
+
* Binds both PTP sockets and starts acting as the grandmaster. It prefers
|
|
2901
|
+
* the privileged ports 319 and 320 and falls back to ephemeral ports on
|
|
2902
|
+
* `EACCES`, so it runs on Linux without root. Some HomePods reject PTP on
|
|
2903
|
+
* non-standard ports.
|
|
2904
|
+
*
|
|
2905
|
+
* @returns The bound event and general ports.
|
|
2906
|
+
*/
|
|
2907
|
+
async start() {
|
|
2908
|
+
if (this.#state !== "idle") return {
|
|
2909
|
+
eventPort: this.#eventPort,
|
|
2910
|
+
generalPort: this.#generalPort
|
|
2911
|
+
};
|
|
2912
|
+
this.#eventSocket = await this.#bindSocket(EVENT_PORT, "event");
|
|
2913
|
+
this.#generalSocket = await this.#bindSocket(GENERAL_PORT, "general");
|
|
2914
|
+
this.#eventPort = this.#eventSocket.address().port;
|
|
2915
|
+
this.#generalPort = this.#generalSocket.address().port;
|
|
2916
|
+
this.#eventSocket.on("message", this.onEventPacket);
|
|
2917
|
+
this.#eventSocket.on("error", this.onEventError);
|
|
2918
|
+
this.#generalSocket.on("message", this.onGeneralPacket);
|
|
2919
|
+
this.#generalSocket.on("error", this.onGeneralError);
|
|
2920
|
+
this.#state = "master";
|
|
2921
|
+
this.#startMasterTimers();
|
|
2922
|
+
this.#logger.info(`Started on event=${this.#eventPort} general=${this.#generalPort} peer=${this.#peerAddress}`);
|
|
2923
|
+
return {
|
|
2924
|
+
eventPort: this.#eventPort,
|
|
2925
|
+
generalPort: this.#generalPort
|
|
2926
|
+
};
|
|
2927
|
+
}
|
|
2928
|
+
/**
|
|
2929
|
+
* Stops the cadence timers and closes both sockets. Safe to call more than once.
|
|
2930
|
+
*/
|
|
2931
|
+
stop() {
|
|
2932
|
+
if (this.#syncTimer) {
|
|
2933
|
+
clearInterval(this.#syncTimer);
|
|
2934
|
+
this.#syncTimer = void 0;
|
|
2935
|
+
}
|
|
2936
|
+
if (this.#announceTimer) {
|
|
2937
|
+
clearInterval(this.#announceTimer);
|
|
2938
|
+
this.#announceTimer = void 0;
|
|
2939
|
+
}
|
|
2940
|
+
try {
|
|
2941
|
+
this.#eventSocket?.close();
|
|
2942
|
+
} catch {}
|
|
2943
|
+
try {
|
|
2944
|
+
this.#generalSocket?.close();
|
|
2945
|
+
} catch {}
|
|
2946
|
+
this.#eventSocket = void 0;
|
|
2947
|
+
this.#generalSocket = void 0;
|
|
2948
|
+
this.#state = "stopped";
|
|
2949
|
+
}
|
|
2950
|
+
/**
|
|
2951
|
+
* Answers a DelayReq on the event port with a DelayResp that carries our
|
|
2952
|
+
* receive timestamp. A master ignores the other event messages.
|
|
2953
|
+
*/
|
|
2954
|
+
onEventPacket(buf, info) {
|
|
2955
|
+
const decoded = decodeMessage(buf);
|
|
2956
|
+
if (!decoded || decoded.type !== 1) return;
|
|
2957
|
+
this.#delayReqsReceived++;
|
|
2958
|
+
const receiveTimestamp = ptpNow();
|
|
2959
|
+
const response = encodeDelayResp(decoded.header.sequenceId, this.#clockIdentity, receiveTimestamp, decoded.header.sourcePortIdentity);
|
|
2960
|
+
this.#generalSocket?.send(response, GENERAL_PORT, info.address, (err) => {
|
|
2961
|
+
if (err) {
|
|
2962
|
+
this.#logger.warn(`DelayResp send failed to ${info.address}:${GENERAL_PORT}`, err);
|
|
2963
|
+
return;
|
|
2964
|
+
}
|
|
2965
|
+
this.#delayRespsSent++;
|
|
2966
|
+
});
|
|
2967
|
+
}
|
|
2968
|
+
/**
|
|
2969
|
+
* Handles an Announce on the general port. When the peer has a strictly
|
|
2970
|
+
* better priority1 than ours, the master yields and stops.
|
|
2971
|
+
*/
|
|
2972
|
+
onGeneralPacket(buf, info) {
|
|
2973
|
+
const decoded = decodeMessage(buf);
|
|
2974
|
+
if (!decoded) return;
|
|
2975
|
+
if (decoded.type !== 11) return;
|
|
2976
|
+
this.#announcesReceived++;
|
|
2977
|
+
const peerPriority1 = decoded.body.grandmasterPriority1;
|
|
2978
|
+
if (peerPriority1 >= 248) return;
|
|
2979
|
+
this.#logger.info(`Peer ${info.address} priority1=${peerPriority1} < ours=${248}, yielding master role`);
|
|
2980
|
+
this.#sendSignalingStop(decoded.header.sourcePortIdentity, info.address);
|
|
2981
|
+
this.stop();
|
|
2982
|
+
}
|
|
2983
|
+
/** Logs the error only; {@link stop} owns the socket lifecycle. */
|
|
2984
|
+
onEventError(err) {
|
|
2985
|
+
this.#logger.warn("Event socket error", err);
|
|
2986
|
+
}
|
|
2987
|
+
/** Logs the error only; {@link stop} owns the socket lifecycle. */
|
|
2988
|
+
onGeneralError(err) {
|
|
2989
|
+
this.#logger.warn("General socket error", err);
|
|
2990
|
+
}
|
|
2991
|
+
/**
|
|
2992
|
+
* Binds `preferredPort` (319 or 320) and falls back to an ephemeral port on `EACCES`.
|
|
2993
|
+
*
|
|
2994
|
+
* @param label - Names the socket in log messages.
|
|
2995
|
+
*/
|
|
2996
|
+
#bindSocket(preferredPort, label) {
|
|
2997
|
+
return new Promise((resolve, reject) => {
|
|
2998
|
+
const socket = createSocket("udp4");
|
|
2999
|
+
const cleanup = () => {
|
|
3000
|
+
socket.removeListener("error", onError);
|
|
3001
|
+
socket.removeListener("listening", onListening);
|
|
3002
|
+
};
|
|
3003
|
+
const onError = (err) => {
|
|
3004
|
+
cleanup();
|
|
3005
|
+
if (err.code === "EACCES") {
|
|
3006
|
+
this.#logger.warn(`Privileged ${label} port ${preferredPort} not available, falling back to ephemeral port. Some HomePods may reject PTP on non-standard ports.`);
|
|
3007
|
+
this.#bindEphemeral(label).then(resolve, reject);
|
|
3008
|
+
return;
|
|
3009
|
+
}
|
|
3010
|
+
reject(err);
|
|
3011
|
+
};
|
|
3012
|
+
const onListening = () => {
|
|
3013
|
+
cleanup();
|
|
3014
|
+
resolve(socket);
|
|
3015
|
+
};
|
|
3016
|
+
socket.once("error", onError);
|
|
3017
|
+
socket.once("listening", onListening);
|
|
3018
|
+
socket.bind(preferredPort);
|
|
3019
|
+
});
|
|
3020
|
+
}
|
|
3021
|
+
/**
|
|
3022
|
+
* Binds a new UDP socket to an ephemeral port.
|
|
3023
|
+
*
|
|
3024
|
+
* @param label - Names the socket in log messages.
|
|
3025
|
+
*/
|
|
3026
|
+
#bindEphemeral(label) {
|
|
3027
|
+
return new Promise((resolve, reject) => {
|
|
3028
|
+
const socket = createSocket("udp4");
|
|
3029
|
+
const cleanup = () => {
|
|
3030
|
+
socket.removeListener("error", onError);
|
|
3031
|
+
socket.removeListener("listening", onListening);
|
|
3032
|
+
};
|
|
3033
|
+
const onError = (err) => {
|
|
3034
|
+
cleanup();
|
|
3035
|
+
reject(err);
|
|
3036
|
+
};
|
|
3037
|
+
const onListening = () => {
|
|
3038
|
+
cleanup();
|
|
3039
|
+
this.#logger.warn(`Bound ${label} socket to ephemeral port ${socket.address().port}`);
|
|
3040
|
+
resolve(socket);
|
|
3041
|
+
};
|
|
3042
|
+
socket.once("error", onError);
|
|
3043
|
+
socket.once("listening", onListening);
|
|
3044
|
+
socket.bind(0);
|
|
3045
|
+
});
|
|
3046
|
+
}
|
|
3047
|
+
#startMasterTimers() {
|
|
3048
|
+
this.#syncTimer = setInterval(() => this.#sendSyncAndFollowUp(), SYNC_INTERVAL_MS);
|
|
3049
|
+
this.#announceTimer = setInterval(() => this.#sendAnnounce(), ANNOUNCE_INTERVAL_MS);
|
|
3050
|
+
}
|
|
3051
|
+
/**
|
|
3052
|
+
* Sends a Sync on the event port and the matching FollowUp on the general
|
|
3053
|
+
* port. In two-step PTP, the FollowUp carries the time the Sync went out.
|
|
3054
|
+
*/
|
|
3055
|
+
#sendSyncAndFollowUp() {
|
|
3056
|
+
if (!this.#eventSocket || !this.#generalSocket) return;
|
|
3057
|
+
const sequenceId = this.#syncSequenceId = this.#syncSequenceId + 1 & 65535;
|
|
3058
|
+
const sync = encodeSync(sequenceId, this.#clockIdentity);
|
|
3059
|
+
this.#eventSocket.send(sync, EVENT_PORT, this.#peerAddress, (err) => {
|
|
3060
|
+
if (err) {
|
|
3061
|
+
this.#logger.warn(`Sync send failed`, err);
|
|
3062
|
+
return;
|
|
3063
|
+
}
|
|
3064
|
+
this.#syncsSent++;
|
|
3065
|
+
const t1 = ptpNow();
|
|
3066
|
+
const followUp = encodeFollowUp(sequenceId, this.#clockIdentity, t1);
|
|
3067
|
+
this.#generalSocket?.send(followUp, GENERAL_PORT, this.#peerAddress, (followUpErr) => {
|
|
3068
|
+
if (!followUpErr) return;
|
|
3069
|
+
this.#logger.warn(`FollowUp send failed`, followUpErr);
|
|
3070
|
+
});
|
|
3071
|
+
});
|
|
3072
|
+
}
|
|
3073
|
+
#sendAnnounce() {
|
|
3074
|
+
if (!this.#generalSocket) return;
|
|
3075
|
+
const announce = encodeAnnounce(this.#announceSequenceId = this.#announceSequenceId + 1 & 65535, this.#clockIdentity);
|
|
3076
|
+
this.#generalSocket.send(announce, GENERAL_PORT, this.#peerAddress, (err) => {
|
|
3077
|
+
if (err) {
|
|
3078
|
+
this.#logger.warn(`Announce send failed`, err);
|
|
3079
|
+
return;
|
|
3080
|
+
}
|
|
3081
|
+
this.#announcesSent++;
|
|
3082
|
+
});
|
|
3083
|
+
}
|
|
3084
|
+
/**
|
|
3085
|
+
* Sends one Signaling message that asks the peer to stop its Sync,
|
|
3086
|
+
* Announce and Pdelay messages, when we yield.
|
|
3087
|
+
*
|
|
3088
|
+
* @param targetPortIdentity - The 10-byte port identity of the peer.
|
|
3089
|
+
* @param address - IPv4 destination address.
|
|
3090
|
+
*/
|
|
3091
|
+
#sendSignalingStop(targetPortIdentity, address) {
|
|
3092
|
+
if (!this.#generalSocket) return;
|
|
3093
|
+
const signaling = encodeSignalingStop(this.#signalingSequenceId = this.#signalingSequenceId + 1 & 65535, this.#clockIdentity, targetPortIdentity);
|
|
3094
|
+
this.#generalSocket.send(signaling, GENERAL_PORT, address, (err) => {
|
|
3095
|
+
if (!err) return;
|
|
3096
|
+
this.#logger.warn(`Signaling stop send failed to ${address}`, err);
|
|
3097
|
+
});
|
|
3098
|
+
}
|
|
3099
|
+
};
|
|
3100
|
+
|
|
3101
|
+
//#endregion
|
|
3102
|
+
//#region src/timing/selector.ts
|
|
3103
|
+
/**
|
|
3104
|
+
* Picks the preferred timing strategy from a receiver's feature flags. The
|
|
3105
|
+
* caller decides at SETUP which strategy it can provide, for example only
|
|
3106
|
+
* `NTP` without a {@link PtpMaster}.
|
|
3107
|
+
*
|
|
3108
|
+
* @param receiverFeatures - The feature bitmask from GET /info.
|
|
3109
|
+
*/
|
|
3110
|
+
function selectTimingStrategy(receiverFeatures) {
|
|
3111
|
+
if (hasFeatureFlag(receiverFeatures, AirPlayFeatureFlags.SupportsPTP)) return "PTP";
|
|
3112
|
+
return "NTP";
|
|
3113
|
+
}
|
|
3114
|
+
|
|
3115
|
+
//#endregion
|
|
3116
|
+
//#region src/session.ts
|
|
3117
|
+
/**
|
|
3118
|
+
* How long the keepalive may go without a successful `/feedback` before the receiver counts as gone.
|
|
3119
|
+
* A receiver only flags an inactive client after tens of seconds, and a busy hub can miss a few ticks.
|
|
3120
|
+
*/
|
|
3121
|
+
const KEEPALIVE_DEADLINE = 3e4;
|
|
3122
|
+
/** A fresh audio session starts silent, so it gets an audible volume. */
|
|
3123
|
+
const DEFAULT_VOLUME_DB = -20;
|
|
3124
|
+
/** Client type of the MRP data stream in its SETUP. */
|
|
3125
|
+
const DATA_CLIENT_TYPE_UUID = "1910A70F-DBC0-4242-AF95-115DB30604E1";
|
|
3126
|
+
/** Package-internal: how the multiplexer reaches into a session without a public method for it. */
|
|
3127
|
+
const audioLinks = /* @__PURE__ */ new WeakMap();
|
|
3128
|
+
/**
|
|
3129
|
+
* Connects to an AirPlay receiver and returns a ready session: TCP, `GET /info`, pair-verify with
|
|
3130
|
+
* the credentials or a transient pairing without, then encryption of the control channel.
|
|
3131
|
+
*
|
|
3132
|
+
* @throws {ConnectionClosedError} When the signal aborts during the handshake.
|
|
3133
|
+
* @throws {PairingError} When pairing fails.
|
|
3134
|
+
*/
|
|
3135
|
+
async function connect(target, options = {}) {
|
|
3136
|
+
return await establish(target, options);
|
|
3137
|
+
}
|
|
3138
|
+
async function establish(target, options, ptpMaster) {
|
|
3139
|
+
const signal = options.signal;
|
|
3140
|
+
if (signal?.aborted) throw new ConnectionClosedError("AirPlay setup was interrupted.");
|
|
3141
|
+
const context = options.context ?? new Context(target.id ?? target.address, options.identity);
|
|
3142
|
+
const control = new ControlChannel(context, target.address, target.port ?? 7e3);
|
|
3143
|
+
const interrupt = () => control.destroy();
|
|
3144
|
+
signal?.addEventListener("abort", interrupt, { once: true });
|
|
3145
|
+
try {
|
|
3146
|
+
await control.connect();
|
|
3147
|
+
const info = await fetchInfo(control, context);
|
|
3148
|
+
const keys = options.credentials ? await verifyPairing(control, context, options.credentials) : await transientPairing(control, context);
|
|
3149
|
+
if (signal?.aborted || !control.isConnected) throw new ConnectionClosedError("AirPlay setup was interrupted.");
|
|
3150
|
+
control.enableEncryption(keys.accessoryToControllerKey, keys.controllerToAccessoryKey);
|
|
3151
|
+
return new AirPlaySession({
|
|
3152
|
+
target,
|
|
3153
|
+
options,
|
|
3154
|
+
context,
|
|
3155
|
+
control,
|
|
3156
|
+
keys,
|
|
3157
|
+
info,
|
|
3158
|
+
ptpMaster
|
|
3159
|
+
});
|
|
3160
|
+
} catch (err) {
|
|
3161
|
+
control.destroy();
|
|
3162
|
+
throw signal?.aborted ? new ConnectionClosedError("AirPlay setup was interrupted.") : err;
|
|
3163
|
+
} finally {
|
|
3164
|
+
signal?.removeEventListener("abort", interrupt);
|
|
3165
|
+
}
|
|
3166
|
+
}
|
|
3167
|
+
/**
|
|
3168
|
+
* One authenticated AirPlay connection. It lives once: it ends exactly once, with a reason, and
|
|
3169
|
+
* then its {@link signal} aborts and every channel, timer and pending request on it goes too.
|
|
3170
|
+
* Remote control runs on the session itself; URL playback and audio streams each get a dedicated
|
|
3171
|
+
* connection, because the receiver wants them apart, and end with the session.
|
|
3172
|
+
*/
|
|
3173
|
+
var AirPlaySession = class extends EventEmitter {
|
|
3174
|
+
/** Why the session ended, or undefined while it is open. */
|
|
3175
|
+
get closeReason() {
|
|
3176
|
+
return this.#closeReason;
|
|
3177
|
+
}
|
|
3178
|
+
get context() {
|
|
3179
|
+
return this.#context;
|
|
3180
|
+
}
|
|
3181
|
+
/** The RTSP control channel, encrypted. */
|
|
3182
|
+
get control() {
|
|
3183
|
+
return this.#control;
|
|
3184
|
+
}
|
|
3185
|
+
/** The feature mask of the receiver from `/info`. */
|
|
3186
|
+
get features() {
|
|
3187
|
+
return this.#info.features;
|
|
3188
|
+
}
|
|
3189
|
+
/** The `/info` plist; empty when the receiver answered with another status than 200. */
|
|
3190
|
+
get info() {
|
|
3191
|
+
return this.#info.info;
|
|
3192
|
+
}
|
|
3193
|
+
get isOpen() {
|
|
3194
|
+
return !this.signal.aborted;
|
|
3195
|
+
}
|
|
3196
|
+
/** Our pairing identifier, from the credentials or the transient pairing. */
|
|
3197
|
+
get pairingId() {
|
|
3198
|
+
return this.#keys.pairingId;
|
|
3199
|
+
}
|
|
3200
|
+
/** The remote control channels once {@link openRemoteControl} succeeded. */
|
|
3201
|
+
get remoteControl() {
|
|
3202
|
+
return this.#remote;
|
|
3203
|
+
}
|
|
3204
|
+
/** Identifies the session in SETUP bodies; uppercase. */
|
|
3205
|
+
get sessionUUID() {
|
|
3206
|
+
return this.#sessionUUID;
|
|
3207
|
+
}
|
|
3208
|
+
/** Aborts with a {@link ConnectionClosedError} when the session ends. */
|
|
3209
|
+
get signal() {
|
|
3210
|
+
return this.#controller.signal;
|
|
3211
|
+
}
|
|
3212
|
+
/** Whether the receiver fetches and plays a URL itself. A HomePod advertises no video feature and needs an audio stream. */
|
|
3213
|
+
get supportsUrlPlayback() {
|
|
3214
|
+
return this.hasFeature(AirPlayFeatureFlags.SupportsAirPlayVideoV1) || this.hasFeature(AirPlayFeatureFlags.SupportsAirPlayVideoV2) || this.hasFeature(AirPlayFeatureFlags.SupportsAirPlayVideoPlayQueue);
|
|
3215
|
+
}
|
|
3216
|
+
get target() {
|
|
3217
|
+
return this.#target;
|
|
3218
|
+
}
|
|
3219
|
+
#context;
|
|
3220
|
+
#control;
|
|
3221
|
+
#controller = new AbortController();
|
|
3222
|
+
#keys;
|
|
3223
|
+
#options;
|
|
3224
|
+
#ptpMaster;
|
|
3225
|
+
#sessionUUID = randomUUID().toUpperCase();
|
|
3226
|
+
#target;
|
|
3227
|
+
#closeReason;
|
|
3228
|
+
#info;
|
|
3229
|
+
#keepalive;
|
|
3230
|
+
#remote;
|
|
3231
|
+
#remoteOpening = false;
|
|
3232
|
+
/** Use {@link connect}; a session starts from a finished handshake. */
|
|
3233
|
+
constructor(parts) {
|
|
3234
|
+
super();
|
|
3235
|
+
this.#context = parts.context;
|
|
3236
|
+
this.#control = parts.control;
|
|
3237
|
+
this.#info = parts.info;
|
|
3238
|
+
this.#keys = parts.keys;
|
|
3239
|
+
this.#options = parts.options;
|
|
3240
|
+
this.#ptpMaster = parts.ptpMaster;
|
|
3241
|
+
this.#target = parts.target;
|
|
3242
|
+
this.#control.on("closed", (reason, error) => this.#channelClosed("control", reason, error));
|
|
3243
|
+
this.#control.on("error", (error) => this.#context.logger.error("AirPlay error", error));
|
|
3244
|
+
parts.options.signal?.addEventListener("abort", () => this.#end("local"), {
|
|
3245
|
+
once: true,
|
|
3246
|
+
signal: this.signal
|
|
3247
|
+
});
|
|
3248
|
+
audioLinks.set(this, (options) => this.#openAudioLink(options));
|
|
3249
|
+
}
|
|
3250
|
+
hasFeature(feature) {
|
|
3251
|
+
return hasFeatureFlag(this.#info.features, feature);
|
|
3252
|
+
}
|
|
3253
|
+
/** Ends the session and every connection that hangs off it, without TEARDOWN. */
|
|
3254
|
+
async close() {
|
|
3255
|
+
this.#end("local");
|
|
3256
|
+
}
|
|
3257
|
+
/**
|
|
3258
|
+
* Opens remote control: the SETUP with the remote control features, the event channel and
|
|
3259
|
+
* RECORD, the SETUP of the MRP data channel and its connection, then the keepalive. The MRP
|
|
3260
|
+
* startup on the data channel is up to the caller, usually `MediaRemote.open()`. A failed step
|
|
3261
|
+
* ends the session with reason `local`.
|
|
3262
|
+
*
|
|
3263
|
+
* @throws {SetupError} When a SETUP fails.
|
|
3264
|
+
* @throws {ConnectionClosedError} When the session ends during setup.
|
|
3265
|
+
*/
|
|
3266
|
+
async openRemoteControl(options = {}) {
|
|
3267
|
+
this.#assertOpen();
|
|
3268
|
+
if (this.#remote || this.#remoteOpening) throw new Error("Remote control is already open on this session.");
|
|
3269
|
+
this.#remoteOpening = true;
|
|
3270
|
+
try {
|
|
3271
|
+
const body = {
|
|
3272
|
+
...this.#setupBody(SENDER_FEATURES_REMOTE_CONTROL),
|
|
3273
|
+
isRemoteControlOnly: true
|
|
3274
|
+
};
|
|
3275
|
+
await this.#applyTiming(body);
|
|
3276
|
+
const events = await this.#connectEvents(await this.#performSetup(body, () => "SETUP request failed."));
|
|
3277
|
+
await this.#control.record(this.#path);
|
|
3278
|
+
this.#checkpoint();
|
|
3279
|
+
this.#observe("event", events);
|
|
3280
|
+
const data = await this.#connectData(options.subscribe);
|
|
3281
|
+
this.#checkpoint();
|
|
3282
|
+
this.#observe("data", data);
|
|
3283
|
+
data.on("setConnectionState", (message) => this.#onConnectionState(message));
|
|
3284
|
+
this.#startKeepalive();
|
|
3285
|
+
this.#remote = {
|
|
3286
|
+
data,
|
|
3287
|
+
events
|
|
3288
|
+
};
|
|
3289
|
+
return this.#remote;
|
|
3290
|
+
} catch (err) {
|
|
3291
|
+
this.#remoteOpening = false;
|
|
3292
|
+
if (this.signal.aborted) throw err instanceof ConnectionClosedError ? err : new ConnectionClosedError("AirPlay setup was interrupted.");
|
|
3293
|
+
this.#context.logger.error("[device]", "Setup failed, cleaning up", err);
|
|
3294
|
+
this.#end("local");
|
|
3295
|
+
throw err;
|
|
3296
|
+
}
|
|
3297
|
+
}
|
|
3298
|
+
/**
|
|
3299
|
+
* Has the receiver fetch and play a URL, on a connection of its own. Resolves once playback
|
|
3300
|
+
* was started; the returned playback ends with the session.
|
|
3301
|
+
*
|
|
3302
|
+
* @throws {PlaybackError} When the receiver cannot play URLs or refuses this one.
|
|
3303
|
+
* @throws {SetupError} When the SETUP fails.
|
|
3304
|
+
*/
|
|
3305
|
+
async playUrl(url, options = {}) {
|
|
3306
|
+
const dedicated = await this.#spawn(options.signal);
|
|
3307
|
+
try {
|
|
3308
|
+
return await dedicated.#startUrlPlayback(url, options.position ?? 0);
|
|
3309
|
+
} catch (err) {
|
|
3310
|
+
dedicated.#end("local");
|
|
3311
|
+
throw dedicated.#interrupted(err, "URL playback cancelled.");
|
|
3312
|
+
}
|
|
3313
|
+
}
|
|
3314
|
+
/**
|
|
3315
|
+
* Streams PCM audio on a connection of its own. Resolves once the audio stream is set up;
|
|
3316
|
+
* {@link AudioPlayback.ended} settles when the source ran out and the stream is torn down.
|
|
3317
|
+
* The source is stopped in every case.
|
|
3318
|
+
*
|
|
3319
|
+
* @throws {SetupError} When a SETUP fails.
|
|
3320
|
+
*/
|
|
3321
|
+
async streamAudio(source, options = {}) {
|
|
3322
|
+
const sourceSession = new AudioSourceSession(source);
|
|
3323
|
+
try {
|
|
3324
|
+
const { session, stream } = await this.#openAudioLink(options);
|
|
3325
|
+
try {
|
|
3326
|
+
await stream.setup();
|
|
3327
|
+
session.#checkpoint();
|
|
3328
|
+
} catch (err) {
|
|
3329
|
+
session.#end("local");
|
|
3330
|
+
throw session.#interrupted(err, "Audio stream cancelled.");
|
|
3331
|
+
}
|
|
3332
|
+
return new AudioPlayback(session, stream, sourceSession);
|
|
3333
|
+
} catch (err) {
|
|
3334
|
+
await sourceSession.stop();
|
|
3335
|
+
throw options.signal?.aborted ? new ConnectionClosedError("Audio stream cancelled.") : err;
|
|
3336
|
+
}
|
|
3337
|
+
}
|
|
3338
|
+
async #openAudioLink(options) {
|
|
3339
|
+
const dedicated = await this.#spawn(options.signal, options.ptpMaster);
|
|
3340
|
+
try {
|
|
3341
|
+
return await dedicated.#openAudio(options.volumeDb ?? DEFAULT_VOLUME_DB, options);
|
|
3342
|
+
} catch (err) {
|
|
3343
|
+
dedicated.#end("local");
|
|
3344
|
+
throw dedicated.#interrupted(err, "Audio stream cancelled.");
|
|
3345
|
+
}
|
|
3346
|
+
}
|
|
3347
|
+
async #openAudio(volumeDb, options) {
|
|
3348
|
+
const body = {
|
|
3349
|
+
...this.#setupBody(SENDER_FEATURES_AUDIO),
|
|
3350
|
+
groupContainsGroupLeader: false,
|
|
3351
|
+
groupUUID: randomUUID().toUpperCase(),
|
|
3352
|
+
isMultiSelectAirPlay: true,
|
|
3353
|
+
senderSupportsRelay: false,
|
|
3354
|
+
statsCollectionEnabled: false,
|
|
3355
|
+
supportsGroupCohesion: true,
|
|
3356
|
+
updateSessionRequest: false
|
|
3357
|
+
};
|
|
3358
|
+
await this.#applyTiming(body);
|
|
3359
|
+
const events = await this.#connectEvents(await this.#performSetup(body, () => "SETUP request failed."));
|
|
3360
|
+
await this.#control.record(this.#path);
|
|
3361
|
+
this.#checkpoint();
|
|
3362
|
+
this.#observe("event", events);
|
|
3363
|
+
await this.#control.setParameter("volume", String(volumeDb));
|
|
3364
|
+
this.#checkpoint();
|
|
3365
|
+
this.#startKeepalive();
|
|
3366
|
+
const stream = new AudioStream({
|
|
3367
|
+
context: this.#context,
|
|
3368
|
+
control: this.#control,
|
|
3369
|
+
address: this.#target.address,
|
|
3370
|
+
supportedAudioFormats: this.#info.info.supportedAudioFormats
|
|
3371
|
+
}, options);
|
|
3372
|
+
this.signal.addEventListener("abort", () => stream.close(), { once: true });
|
|
3373
|
+
return {
|
|
3374
|
+
session: this,
|
|
3375
|
+
stream
|
|
3376
|
+
};
|
|
3377
|
+
}
|
|
3378
|
+
async #startUrlPlayback(url, position) {
|
|
3379
|
+
if (!this.supportsUrlPlayback) throw new PlaybackError("This receiver does not support URL playback. Stream the URL as audio instead.");
|
|
3380
|
+
const body = {
|
|
3381
|
+
...this.#setupBody(SENDER_FEATURES_AUDIO),
|
|
3382
|
+
groupContainsGroupLeader: false,
|
|
3383
|
+
groupUUID: randomUUID(),
|
|
3384
|
+
isMultiSelectAirPlay: true,
|
|
3385
|
+
senderSupportsRelay: false,
|
|
3386
|
+
statsCollectionEnabled: false,
|
|
3387
|
+
supportsGroupCohesion: true,
|
|
3388
|
+
updateSessionRequest: false
|
|
3389
|
+
};
|
|
3390
|
+
await this.#applyTiming(body);
|
|
3391
|
+
const events = await this.#connectEvents(await this.#performSetup(body, (status) => `Failed to setup for playback: ${status}`));
|
|
3392
|
+
this.#startKeepalive();
|
|
3393
|
+
try {
|
|
3394
|
+
this.#info = await fetchInfo(this.#control, this.#context, this.#info);
|
|
3395
|
+
} catch (err) {
|
|
3396
|
+
this.#context.logger.warn("[protocol]", "Second GET /info failed", err);
|
|
3397
|
+
}
|
|
3398
|
+
await this.#control.record(this.#path);
|
|
3399
|
+
this.#checkpoint();
|
|
3400
|
+
this.#observe("event", events);
|
|
3401
|
+
try {
|
|
3402
|
+
return await UrlPlayback.start({
|
|
3403
|
+
session: this,
|
|
3404
|
+
context: this.#context,
|
|
3405
|
+
control: this.#control,
|
|
3406
|
+
events,
|
|
3407
|
+
features: this.#info.features,
|
|
3408
|
+
sessionUUID: this.#sessionUUID,
|
|
3409
|
+
senderMac: body.macAddress
|
|
3410
|
+
}, url, position);
|
|
3411
|
+
} catch (err) {
|
|
3412
|
+
this.#stopKeepalive();
|
|
3413
|
+
try {
|
|
3414
|
+
await this.#control.teardown(this.#path);
|
|
3415
|
+
} catch (teardownErr) {
|
|
3416
|
+
this.#context.logger.warn("[protocol]", "TEARDOWN after playUrl failure also failed", teardownErr);
|
|
3417
|
+
}
|
|
3418
|
+
throw err;
|
|
3419
|
+
}
|
|
3420
|
+
}
|
|
3421
|
+
get #path() {
|
|
3422
|
+
return `/${this.#control.sessionId}`;
|
|
3423
|
+
}
|
|
3424
|
+
/**
|
|
3425
|
+
* Fills `timingProtocol` and `timingPort`: PTP when the receiver supports it and a PTP master
|
|
3426
|
+
* was given, NTP when a timing server was given, otherwise `None`.
|
|
3427
|
+
*/
|
|
3428
|
+
async #applyTiming(body) {
|
|
3429
|
+
const master = this.#ptpMaster;
|
|
3430
|
+
if (master && selectTimingStrategy(this.#info.features) === "PTP") {
|
|
3431
|
+
body.timingPort = master.state === "idle" ? (await master.start()).eventPort : master.eventPort;
|
|
3432
|
+
body.timingProtocol = "PTP";
|
|
3433
|
+
return;
|
|
3434
|
+
}
|
|
3435
|
+
if (this.#options.timing) {
|
|
3436
|
+
body.timingPort = this.#options.timing.port;
|
|
3437
|
+
body.timingProtocol = "NTP";
|
|
3438
|
+
return;
|
|
3439
|
+
}
|
|
3440
|
+
body.timingProtocol = "None";
|
|
3441
|
+
}
|
|
3442
|
+
#assertOpen() {
|
|
3443
|
+
if (this.signal.aborted) throw new ConnectionClosedError("This AirPlay session has closed.");
|
|
3444
|
+
}
|
|
3445
|
+
/** Throws when the session ended while a setup step was waiting. */
|
|
3446
|
+
#checkpoint() {
|
|
3447
|
+
if (this.signal.aborted) throw new ConnectionClosedError("AirPlay setup was interrupted.");
|
|
3448
|
+
}
|
|
3449
|
+
/**
|
|
3450
|
+
* A channel the session did not close itself counts as broken, also when someone destroyed it locally.
|
|
3451
|
+
* Once TEARDOWN went out, a receiver may drop the event or data channel before it answers on the
|
|
3452
|
+
* control channel, so the session then waits for that answer or its timeout.
|
|
3453
|
+
*/
|
|
3454
|
+
#channelClosed(name, reason, error) {
|
|
3455
|
+
const teardown = this.#control.pendingTeardown;
|
|
3456
|
+
if (name !== "control" && reason === "remote" && teardown) {
|
|
3457
|
+
this.#context.logger.debug("[session]", `The ${name} channel closed during TEARDOWN, ending the session once it is answered.`);
|
|
3458
|
+
teardown.then(() => this.#end("local"));
|
|
3459
|
+
return;
|
|
3460
|
+
}
|
|
3461
|
+
if (reason === "local") {
|
|
3462
|
+
this.#end("error", error ?? new ConnectionClosedError(`The ${name} channel was closed.`));
|
|
3463
|
+
return;
|
|
3464
|
+
}
|
|
3465
|
+
this.#end(reason, error);
|
|
3466
|
+
}
|
|
3467
|
+
async #connectData(subscribe) {
|
|
3468
|
+
const seed = randomInt64();
|
|
3469
|
+
const response = await this.#control.setup(this.#path, { streams: [{
|
|
3470
|
+
controlType: 2,
|
|
3471
|
+
channelID: randomUUID().toUpperCase(),
|
|
3472
|
+
seed,
|
|
3473
|
+
clientUUID: randomUUID().toUpperCase(),
|
|
3474
|
+
type: 130,
|
|
3475
|
+
wantsDedicatedSocket: true,
|
|
3476
|
+
clientTypeUUID: DATA_CLIENT_TYPE_UUID
|
|
3477
|
+
}] });
|
|
3478
|
+
if (response.status !== 200) {
|
|
3479
|
+
this.#context.logger.error("[protocol]", "Failed to setup data stream.", response.status, response.statusText, await response.text());
|
|
3480
|
+
throw new SetupError("Failed to setup data stream.");
|
|
3481
|
+
}
|
|
3482
|
+
const dataPort = Plist.parse(await response.arrayBuffer()).streams[0].dataPort & 65535;
|
|
3483
|
+
this.#checkpoint();
|
|
3484
|
+
this.#context.logger.net("[protocol]", `Connecting to data stream on port ${dataPort}...`);
|
|
3485
|
+
const data = new DataChannel(this.#context, this.#target.address, dataPort, this.#keys.sharedSecret, seed);
|
|
3486
|
+
this.signal.addEventListener("abort", () => data.destroy(), { once: true });
|
|
3487
|
+
subscribe?.(data);
|
|
3488
|
+
await data.connect();
|
|
3489
|
+
return data;
|
|
3490
|
+
}
|
|
3491
|
+
async #connectEvents(setup) {
|
|
3492
|
+
const eventPort = setup.eventPort & 65535;
|
|
3493
|
+
this.#checkpoint();
|
|
3494
|
+
this.#context.logger.net("[protocol]", `Connecting to event stream on port ${eventPort}...`);
|
|
3495
|
+
const events = new EventChannel(this.#context, this.#target.address, eventPort, this.#keys.sharedSecret);
|
|
3496
|
+
this.signal.addEventListener("abort", () => events.destroy(), { once: true });
|
|
3497
|
+
await events.connect();
|
|
3498
|
+
return events;
|
|
3499
|
+
}
|
|
3500
|
+
/** The single exit: aborts the signal (children clean up there), closes the control channel and emits `closed`. */
|
|
3501
|
+
#end(reason, error) {
|
|
3502
|
+
if (this.#closeReason) return;
|
|
3503
|
+
this.#closeReason = reason;
|
|
3504
|
+
this.#context.logger.net("[session]", `AirPlay session closed (${reason}${error ? `: ${error.message}` : ""}).`);
|
|
3505
|
+
this.#stopKeepalive();
|
|
3506
|
+
this.#controller.abort(new ConnectionClosedError(`AirPlay session closed (${reason}).`));
|
|
3507
|
+
this.#control.destroy();
|
|
3508
|
+
try {
|
|
3509
|
+
this.#ptpMaster?.stop();
|
|
3510
|
+
} catch (err) {
|
|
3511
|
+
this.#context.logger.warn("[protocol]", "Error stopping PTP master", err);
|
|
3512
|
+
}
|
|
3513
|
+
this.emit("closed", reason, error);
|
|
3514
|
+
}
|
|
3515
|
+
/** Turns the error of a setup that the caller or the parent session interrupted into a {@link ConnectionClosedError}. */
|
|
3516
|
+
#interrupted(err, message) {
|
|
3517
|
+
return this.#options.signal?.aborted ? new ConnectionClosedError(message) : err;
|
|
3518
|
+
}
|
|
3519
|
+
/** A channel counts once it is connected; a close or error after that ends the session. */
|
|
3520
|
+
#observe(name, channel) {
|
|
3521
|
+
if (!channel.isConnected) throw new ConnectionClosedError("An AirPlay stream closed during setup.");
|
|
3522
|
+
channel.on("closed", (reason, error) => this.#channelClosed(name, reason, error));
|
|
3523
|
+
channel.on("error", (error) => {
|
|
3524
|
+
this.#context.logger.error("AirPlay stream error", error);
|
|
3525
|
+
this.#end("error", error);
|
|
3526
|
+
});
|
|
3527
|
+
}
|
|
3528
|
+
/**
|
|
3529
|
+
* The receiver answers a message it rejects with Disconnected and then ignores the session, so
|
|
3530
|
+
* the session has to go down with it instead of staying up in name only.
|
|
3531
|
+
*/
|
|
3532
|
+
#onConnectionState(message) {
|
|
3533
|
+
if (message.state !== 3) return;
|
|
3534
|
+
this.#context.logger.error("AirPlay session dropped by the device");
|
|
3535
|
+
this.#end("remote", new ConnectionClosedError("The receiver dropped the session."));
|
|
3536
|
+
}
|
|
3537
|
+
async #performSetup(body, failure) {
|
|
3538
|
+
const response = await this.#control.setup(this.#path, body);
|
|
3539
|
+
if (response.status !== 200) {
|
|
3540
|
+
this.#context.logger.error("[protocol]", "Failed SETUP request.", response.status, response.statusText);
|
|
3541
|
+
throw new SetupError(failure(response.status));
|
|
3542
|
+
}
|
|
3543
|
+
const plist = Plist.parse(await response.arrayBuffer());
|
|
3544
|
+
if (plist.enabledFeatures != null) this.#context.logger.info("[protocol]", `Receiver enabled features: 0x${BigInt(plist.enabledFeatures).toString(16)}`);
|
|
3545
|
+
if (plist.keepAlivePort != null) this.#context.logger.info("[protocol]", `Receiver keep-alive port: ${plist.keepAlivePort}`);
|
|
3546
|
+
return plist;
|
|
3547
|
+
}
|
|
3548
|
+
/** The common SETUP body: identity, features, our MAC and the session UUID. */
|
|
3549
|
+
#setupBody(features) {
|
|
3550
|
+
const identity = resolveIdentity(this.#context);
|
|
3551
|
+
return {
|
|
3552
|
+
deviceID: this.#keys.pairingId.toString(),
|
|
3553
|
+
...encodeFeatures(features),
|
|
3554
|
+
macAddress: getMacAddress().toUpperCase(),
|
|
3555
|
+
model: identity.model,
|
|
3556
|
+
name: identity.name,
|
|
3557
|
+
osBuildVersion: identity.osBuildVersion,
|
|
3558
|
+
osName: identity.osName,
|
|
3559
|
+
osVersion: identity.osVersion,
|
|
3560
|
+
sourceVersion: this.#info.sourceVersion,
|
|
3561
|
+
sessionUUID: this.#sessionUUID,
|
|
3562
|
+
sessionCorrelationUUID: this.#sessionUUID.toUpperCase()
|
|
3563
|
+
};
|
|
3564
|
+
}
|
|
3565
|
+
/** A new connection to the same receiver with the same credentials and timing, ending with this session. */
|
|
3566
|
+
async #spawn(signal, ptpMaster) {
|
|
3567
|
+
this.#assertOpen();
|
|
3568
|
+
return await establish(this.#target, {
|
|
3569
|
+
...this.#options,
|
|
3570
|
+
context: this.#context,
|
|
3571
|
+
signal: signal ? AbortSignal.any([this.signal, signal]) : this.signal
|
|
3572
|
+
}, ptpMaster);
|
|
3573
|
+
}
|
|
3574
|
+
/**
|
|
3575
|
+
* `POST /feedback` every two seconds. A tick that finds no success within {@link KEEPALIVE_DEADLINE}
|
|
3576
|
+
* ends the session with `timeout`.
|
|
3577
|
+
*/
|
|
3578
|
+
#startKeepalive() {
|
|
3579
|
+
if (this.#keepalive || this.signal.aborted) return;
|
|
3580
|
+
let lastSuccess = performance.now();
|
|
3581
|
+
let lastError;
|
|
3582
|
+
this.#keepalive = setInterval(async () => {
|
|
3583
|
+
if (performance.now() - lastSuccess >= KEEPALIVE_DEADLINE) {
|
|
3584
|
+
this.#end("timeout", lastError ?? new TimeoutError(`No successful feedback for ${KEEPALIVE_DEADLINE} ms.`));
|
|
3585
|
+
return;
|
|
3586
|
+
}
|
|
3587
|
+
try {
|
|
3588
|
+
await this.#control.feedback();
|
|
3589
|
+
lastSuccess = performance.now();
|
|
3590
|
+
lastError = void 0;
|
|
3591
|
+
} catch (err) {
|
|
3592
|
+
if (this.signal.aborted) return;
|
|
3593
|
+
lastError = err instanceof Error ? err : new Error(String(err));
|
|
3594
|
+
this.#context.logger.warn("[protocol]", `Feedback failed (${Math.round((performance.now() - lastSuccess) / 1e3)} s since the last success).`, err);
|
|
3595
|
+
}
|
|
3596
|
+
}, FEEDBACK_INTERVAL);
|
|
3597
|
+
}
|
|
3598
|
+
#stopKeepalive() {
|
|
3599
|
+
clearInterval(this.#keepalive);
|
|
3600
|
+
this.#keepalive = void 0;
|
|
3601
|
+
}
|
|
3602
|
+
};
|
|
3603
|
+
|
|
3604
|
+
//#endregion
|
|
3605
|
+
//#region src/discovery.ts
|
|
3606
|
+
const AIRPLAY_SERVICE = "_airplay._tcp.local";
|
|
3607
|
+
/** Finds the AirPlay receivers on the local network. */
|
|
3608
|
+
async function discover(options = {}) {
|
|
3609
|
+
return (await new Discovery(AIRPLAY_SERVICE).find(options.useCache ?? true)).map(withFeatures);
|
|
3610
|
+
}
|
|
3611
|
+
/** The connect target for a discovered receiver. */
|
|
3612
|
+
function toTarget(result) {
|
|
3613
|
+
const { features } = withFeatures(result);
|
|
3614
|
+
return {
|
|
3615
|
+
address: result.address,
|
|
3616
|
+
port: result.service.port,
|
|
3617
|
+
id: result.id,
|
|
3618
|
+
txt: result.txt,
|
|
3619
|
+
features
|
|
3620
|
+
};
|
|
3621
|
+
}
|
|
3622
|
+
/** Adds the parsed feature mask of the `features` (or `ft`) TXT value. */
|
|
3623
|
+
function withFeatures(result) {
|
|
3624
|
+
const features = result.txt.features ?? result.txt.ft;
|
|
3625
|
+
if (!features) return result;
|
|
3626
|
+
try {
|
|
3627
|
+
return {
|
|
3628
|
+
...result,
|
|
3629
|
+
features: parseFeatures(features)
|
|
3630
|
+
};
|
|
3631
|
+
} catch {
|
|
3632
|
+
return result;
|
|
3633
|
+
}
|
|
3634
|
+
}
|
|
3635
|
+
|
|
3636
|
+
//#endregion
|
|
3637
|
+
export { AIRPLAY_SERVICE, AUDIO_BYTES_PER_CHANNEL, AUDIO_CHANNELS, AUDIO_SAMPLE_RATE, AirPlayFeatureFlags, AirPlaySession, AudioPlayback, DataChannel, EventChannel, PinPairing, PtpMaster, TimingServer, UrlPlayback, connect, describeFlags, discover, generateActiveRemoteId, generateDacpId, generateSessionId, getPairingRequirement, getProtocolVersion, hasFeatureFlag, isPasswordRequired, isRemoteControlSupported, pair, parseFeatures, toTarget, withFeatures };
|