lox-airplay-sender 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/README.md +85 -0
  2. package/dist/core/ap2_test.d.ts +1 -0
  3. package/dist/core/ap2_test.js +8 -0
  4. package/dist/core/atv.d.ts +16 -0
  5. package/dist/core/atv.js +215 -0
  6. package/dist/core/atvAuthenticator.d.ts +30 -0
  7. package/dist/core/atvAuthenticator.js +134 -0
  8. package/dist/core/audioOut.d.ts +30 -0
  9. package/dist/core/audioOut.js +80 -0
  10. package/dist/core/deviceAirtunes.d.ts +72 -0
  11. package/dist/core/deviceAirtunes.js +501 -0
  12. package/dist/core/devices.d.ts +50 -0
  13. package/dist/core/devices.js +209 -0
  14. package/dist/core/index.d.ts +47 -0
  15. package/dist/core/index.js +97 -0
  16. package/dist/core/rtsp.d.ts +12 -0
  17. package/dist/core/rtsp.js +1590 -0
  18. package/dist/core/srp.d.ts +14 -0
  19. package/dist/core/srp.js +128 -0
  20. package/dist/core/udpServers.d.ts +26 -0
  21. package/dist/core/udpServers.js +149 -0
  22. package/dist/esm/core/ap2_test.js +8 -0
  23. package/dist/esm/core/atv.js +215 -0
  24. package/dist/esm/core/atvAuthenticator.js +134 -0
  25. package/dist/esm/core/audioOut.js +80 -0
  26. package/dist/esm/core/deviceAirtunes.js +501 -0
  27. package/dist/esm/core/devices.js +209 -0
  28. package/dist/esm/core/index.js +97 -0
  29. package/dist/esm/core/rtsp.js +1590 -0
  30. package/dist/esm/core/srp.js +128 -0
  31. package/dist/esm/core/udpServers.js +149 -0
  32. package/dist/esm/homekit/credentials.js +100 -0
  33. package/dist/esm/homekit/encryption.js +82 -0
  34. package/dist/esm/homekit/number.js +47 -0
  35. package/dist/esm/homekit/tlv.js +97 -0
  36. package/dist/esm/index.js +265 -0
  37. package/dist/esm/package.json +1 -0
  38. package/dist/esm/utils/alac.js +62 -0
  39. package/dist/esm/utils/alacEncoder.js +34 -0
  40. package/dist/esm/utils/circularBuffer.js +124 -0
  41. package/dist/esm/utils/config.js +28 -0
  42. package/dist/esm/utils/http.js +148 -0
  43. package/dist/esm/utils/ntp.js +27 -0
  44. package/dist/esm/utils/numUtil.js +17 -0
  45. package/dist/esm/utils/packetPool.js +52 -0
  46. package/dist/esm/utils/util.js +9 -0
  47. package/dist/homekit/credentials.d.ts +30 -0
  48. package/dist/homekit/credentials.js +100 -0
  49. package/dist/homekit/encryption.d.ts +12 -0
  50. package/dist/homekit/encryption.js +82 -0
  51. package/dist/homekit/number.d.ts +7 -0
  52. package/dist/homekit/number.js +47 -0
  53. package/dist/homekit/tlv.d.ts +25 -0
  54. package/dist/homekit/tlv.js +97 -0
  55. package/dist/index.d.ts +109 -0
  56. package/dist/index.js +265 -0
  57. package/dist/utils/alac.d.ts +9 -0
  58. package/dist/utils/alac.js +62 -0
  59. package/dist/utils/alacEncoder.d.ts +14 -0
  60. package/dist/utils/alacEncoder.js +34 -0
  61. package/dist/utils/circularBuffer.d.ts +31 -0
  62. package/dist/utils/circularBuffer.js +124 -0
  63. package/dist/utils/config.d.ts +25 -0
  64. package/dist/utils/config.js +28 -0
  65. package/dist/utils/http.d.ts +19 -0
  66. package/dist/utils/http.js +148 -0
  67. package/dist/utils/ntp.d.ts +7 -0
  68. package/dist/utils/ntp.js +27 -0
  69. package/dist/utils/numUtil.d.ts +5 -0
  70. package/dist/utils/numUtil.js +17 -0
  71. package/dist/utils/packetPool.d.ts +25 -0
  72. package/dist/utils/packetPool.js +52 -0
  73. package/dist/utils/util.d.ts +2 -0
  74. package/dist/utils/util.js +9 -0
  75. package/package.json +62 -0
@@ -0,0 +1,265 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.LoxAirplaySender = void 0;
7
+ exports.start = start;
8
+ const node_events_1 = require("node:events");
9
+ // Airtunes implementation (node_airtunes2 port) in src/core.
10
+ const index_1 = __importDefault(require("./core/index"));
11
+ const config_1 = __importDefault(require("./utils/config"));
12
+ class LoxAirplaySender extends node_events_1.EventEmitter {
13
+ airtunes = null;
14
+ deviceKey = null;
15
+ started = false;
16
+ source = null;
17
+ log;
18
+ lastTrackKey = null;
19
+ lastCoverKey = null;
20
+ lastProgressKey = null;
21
+ lastCoverUrl = null;
22
+ coverFetch;
23
+ artworkTimer;
24
+ pendingArtwork;
25
+ lastTrackChangeAt = 0;
26
+ /**
27
+ * Create + start a sender for a single AirPlay device.
28
+ * Returns true when the pipeline initializes; safe to call multiple times to restart.
29
+ */
30
+ start(options, onEvent) {
31
+ if (this.started) {
32
+ this.stop();
33
+ }
34
+ this.log = options.log;
35
+ const inputCodec = options.inputCodec ?? 'pcm';
36
+ config_1.default.packet_size = inputCodec === 'alac' ? config_1.default.alac_packet_size : config_1.default.pcm_packet_size;
37
+ this.airtunes = new index_1.default({
38
+ packetSize: config_1.default.packet_size,
39
+ startTimeMs: options.startTimeMs,
40
+ });
41
+ this.airtunes.on('device', (key, status, desc) => {
42
+ onEvent?.({ event: 'device', message: status, detail: { key, desc } });
43
+ });
44
+ this.airtunes.on('buffer', (status) => {
45
+ onEvent?.({ event: 'buffer', message: status });
46
+ });
47
+ this.airtunes.on('error', (err) => {
48
+ onEvent?.({ event: 'error', message: err instanceof Error ? err.message : String(err) });
49
+ });
50
+ const dev = this.airtunes.add(options.host, {
51
+ port: options.port,
52
+ name: options.name,
53
+ password: options.password ?? null,
54
+ volume: options.volume ?? 50,
55
+ mode: options.mode ?? (options.airplay2 ? 2 : 0),
56
+ txt: options.txt ?? [],
57
+ forceAlac: options.forceAlac ?? true,
58
+ alacEncoding: options.alacEncoding ?? true,
59
+ inputCodec,
60
+ airplay2: options.airplay2 ?? false,
61
+ debug: options.debug ?? false,
62
+ log: options.log,
63
+ });
64
+ this.deviceKey = dev?.key ?? `${options.host}:${options.port ?? 5000}`;
65
+ this.started = true;
66
+ return true;
67
+ }
68
+ /**
69
+ * Push raw PCM or ALAC frames into the stream.
70
+ */
71
+ sendPcm(chunk) {
72
+ if (!this.airtunes || !this.started)
73
+ return;
74
+ this.airtunes.write(chunk);
75
+ }
76
+ /**
77
+ * Pipe a readable stream into the sender; auto-stops on end/error.
78
+ */
79
+ pipeStream(stream) {
80
+ if (!this.airtunes || !this.started)
81
+ return;
82
+ this.source = stream;
83
+ stream.on('data', (chunk) => this.sendPcm(chunk));
84
+ stream.on('end', () => this.stop());
85
+ stream.on('error', () => this.stop());
86
+ }
87
+ /** Adjust receiver volume (0–100). */
88
+ setVolume(volume) {
89
+ if (!this.airtunes || !this.deviceKey)
90
+ return;
91
+ this.airtunes.setVolume(this.deviceKey, volume);
92
+ }
93
+ /** Update track metadata immediately without artwork/progress. */
94
+ setTrackInfo(title, artist, album) {
95
+ if (!this.airtunes || !this.deviceKey)
96
+ return;
97
+ this.airtunes.setTrackInfo(this.deviceKey, title, artist, album);
98
+ }
99
+ /** Send cover art immediately. */
100
+ setArtwork(art, contentType) {
101
+ if (!this.airtunes || !this.deviceKey)
102
+ return;
103
+ this.airtunes.setArtwork(this.deviceKey, art, contentType);
104
+ }
105
+ /** Send playback progress in seconds (elapsed, duration). */
106
+ setProgress(progress, duration) {
107
+ if (!this.airtunes || !this.deviceKey)
108
+ return;
109
+ this.airtunes.setProgress(this.deviceKey, progress, duration);
110
+ }
111
+ /**
112
+ * Convenience to send track info, cover (buffer or URL), and progress.
113
+ * Deduplicates payloads and staggers artwork on track changes.
114
+ */
115
+ async setMetadata(payload) {
116
+ if (!this.airtunes || !this.deviceKey)
117
+ return;
118
+ const title = payload.title ?? '';
119
+ const artist = payload.artist ?? '';
120
+ const album = payload.album ?? '';
121
+ const trackKey = `${title}::${artist}::${album}`;
122
+ const trackChanged = Boolean(title && trackKey !== this.lastTrackKey);
123
+ if (trackChanged) {
124
+ this.setTrackInfo(title, artist, album);
125
+ this.lastTrackKey = trackKey;
126
+ this.lastCoverKey = null;
127
+ this.lastCoverUrl = null;
128
+ this.lastTrackChangeAt = Date.now();
129
+ }
130
+ let coverPayload = payload.cover;
131
+ const coverUrl = payload.coverUrl;
132
+ if (!coverPayload?.data && coverUrl) {
133
+ if (coverUrl !== this.lastCoverUrl && !this.coverFetch) {
134
+ this.lastCoverUrl = coverUrl;
135
+ this.lastCoverKey = null;
136
+ this.coverFetch = this.fetchCover(coverUrl).finally(() => {
137
+ this.coverFetch = undefined;
138
+ });
139
+ }
140
+ if (this.coverFetch) {
141
+ coverPayload = (await this.coverFetch) ?? undefined;
142
+ }
143
+ }
144
+ if (coverPayload?.data) {
145
+ const coverKey = coverUrl
146
+ ? `${coverUrl}:${coverPayload.mime ?? 'unknown'}:${coverPayload.data.length}`
147
+ : `${coverPayload.mime ?? 'unknown'}:${coverPayload.data.length}`;
148
+ if (coverKey !== this.lastCoverKey) {
149
+ if (trackChanged) {
150
+ this.queueArtwork(coverPayload, coverKey, 200);
151
+ }
152
+ else {
153
+ this.sendArtworkNow(coverPayload, coverKey);
154
+ }
155
+ }
156
+ }
157
+ const durationInput = typeof payload.durationMs === 'number' && payload.durationMs > 0 ? payload.durationMs : null;
158
+ const elapsedInput = typeof payload.elapsedMs === 'number' && payload.elapsedMs >= 0 ? payload.elapsedMs : null;
159
+ const durationSec = durationInput !== null ? Math.floor(durationInput > 1000 ? durationInput / 1000 : durationInput) : null;
160
+ const elapsedSecRaw = elapsedInput !== null ? Math.floor(elapsedInput > 1000 ? elapsedInput / 1000 : elapsedInput) : null;
161
+ if (durationSec !== null && elapsedSecRaw !== null && durationSec > 0) {
162
+ const elapsedSec = Math.min(Math.max(0, elapsedSecRaw), durationSec);
163
+ const progressKey = `${elapsedSec}/${durationSec}`;
164
+ if (progressKey !== this.lastProgressKey) {
165
+ this.setProgress(elapsedSec, durationSec);
166
+ this.lastProgressKey = progressKey;
167
+ }
168
+ }
169
+ }
170
+ /** Provide a passcode when a receiver requests it. */
171
+ setPasscode(passcode) {
172
+ if (!this.airtunes || !this.deviceKey)
173
+ return;
174
+ this.airtunes.setPasscode(this.deviceKey, passcode);
175
+ }
176
+ /**
177
+ * Stop streaming and tear down state/sockets. Safe to call multiple times.
178
+ */
179
+ stop() {
180
+ if (this.source) {
181
+ try {
182
+ this.source.destroy();
183
+ }
184
+ catch {
185
+ // ignore
186
+ }
187
+ this.source = null;
188
+ }
189
+ this.lastTrackKey = null;
190
+ this.lastCoverKey = null;
191
+ this.lastProgressKey = null;
192
+ this.lastCoverUrl = null;
193
+ this.lastTrackChangeAt = 0;
194
+ this.pendingArtwork = undefined;
195
+ if (this.artworkTimer) {
196
+ clearTimeout(this.artworkTimer);
197
+ this.artworkTimer = undefined;
198
+ }
199
+ if (this.airtunes) {
200
+ if (this.deviceKey) {
201
+ this.airtunes.stop(this.deviceKey);
202
+ }
203
+ this.airtunes.stopAll?.(() => undefined);
204
+ this.airtunes.end?.();
205
+ }
206
+ this.airtunes = null;
207
+ this.deviceKey = null;
208
+ this.started = false;
209
+ }
210
+ async fetchCover(url) {
211
+ const controller = new AbortController();
212
+ const timeout = setTimeout(() => controller.abort(), 4000);
213
+ try {
214
+ const response = await fetch(url, { signal: controller.signal });
215
+ if (!response.ok) {
216
+ this.log?.('warn', 'airplay cover fetch failed', { status: response.status, url });
217
+ return null;
218
+ }
219
+ const mime = response.headers.get('content-type') || undefined;
220
+ const buffer = Buffer.from(await response.arrayBuffer());
221
+ if (!buffer.length) {
222
+ this.log?.('warn', 'airplay cover fetch empty', { url });
223
+ return null;
224
+ }
225
+ return { data: buffer, mime };
226
+ }
227
+ catch {
228
+ this.log?.('warn', 'airplay cover fetch error', { url });
229
+ return null;
230
+ }
231
+ finally {
232
+ clearTimeout(timeout);
233
+ }
234
+ }
235
+ sendArtworkNow(payload, coverKey) {
236
+ this.setArtwork(payload.data, payload.mime);
237
+ this.lastCoverKey = coverKey;
238
+ }
239
+ queueArtwork(payload, coverKey, delayMs) {
240
+ this.pendingArtwork = { ...payload, key: coverKey };
241
+ if (this.artworkTimer) {
242
+ clearTimeout(this.artworkTimer);
243
+ }
244
+ this.artworkTimer = setTimeout(() => {
245
+ this.artworkTimer = undefined;
246
+ const pending = this.pendingArtwork;
247
+ this.pendingArtwork = undefined;
248
+ if (!pending)
249
+ return;
250
+ if (pending.key === this.lastCoverKey)
251
+ return;
252
+ this.sendArtworkNow({ data: pending.data, mime: pending.mime }, pending.key);
253
+ }, delayMs);
254
+ }
255
+ }
256
+ exports.LoxAirplaySender = LoxAirplaySender;
257
+ /**
258
+ * Convenience helper to construct + start a sender in one call.
259
+ */
260
+ function start(options, onEvent) {
261
+ const sender = new LoxAirplaySender();
262
+ sender.start(options, onEvent);
263
+ return sender;
264
+ }
265
+ exports.default = LoxAirplaySender;
@@ -0,0 +1 @@
1
+ {"type":"module"}
@@ -0,0 +1,62 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.ALAC_PACKET_SIZE = exports.PCM_PACKET_SIZE = void 0;
7
+ exports.encodePcmToAlac = encodePcmToAlac;
8
+ const config_1 = __importDefault(require("./config"));
9
+ /** PCM packet size (bytes) expected by the encoder. */
10
+ exports.PCM_PACKET_SIZE = config_1.default.pcm_packet_size;
11
+ /** Output ALAC packet size (bytes). */
12
+ exports.ALAC_PACKET_SIZE = config_1.default.alac_packet_size;
13
+ /**
14
+ * Encode one PCM frame (16-bit LE stereo, 44.1kHz) into ALAC.
15
+ * Input must be exactly `PCM_PACKET_SIZE` bytes.
16
+ */
17
+ function encodePcmToAlac(pcmData) {
18
+ let alacData = Buffer.alloc(exports.ALAC_PACKET_SIZE);
19
+ const bsize = 352;
20
+ const frames = 352;
21
+ const p = new Uint8Array(exports.ALAC_PACKET_SIZE);
22
+ const input = new Uint32Array(pcmData.length / 4);
23
+ let j = 0;
24
+ for (let i = 0; i < pcmData.length; i += 4) {
25
+ let res = pcmData[i];
26
+ res |= pcmData[i + 1] << 8;
27
+ res |= pcmData[i + 2] << 16;
28
+ res |= pcmData[i + 3] << 24;
29
+ input[j++] = res;
30
+ }
31
+ let pindex = 0;
32
+ let iindex = 0;
33
+ p[pindex++] = 1 << 5;
34
+ p[pindex++] = 0;
35
+ p[pindex++] = (1 << 4) | (1 << 1) | ((bsize & 0x80000000) >>> 31);
36
+ p[pindex++] = ((bsize & 0x7f800000) << 1) >>> 24;
37
+ p[pindex++] = ((bsize & 0x007f8000) << 1) >>> 16;
38
+ p[pindex++] = ((bsize & 0x00007f80) << 1) >>> 8;
39
+ p[pindex] = (bsize & 0x0000007f) << 1;
40
+ p[pindex++] |= (input[iindex] & 0x00008000) >>> 15;
41
+ let count = frames - 1;
42
+ while (count--) {
43
+ const i = input[iindex++];
44
+ p[pindex++] = (i & 0x00007f80) >>> 7;
45
+ p[pindex++] = ((i & 0x0000007f) << 1) | ((i & 0x80000000) >>> 31);
46
+ p[pindex++] = (i & 0x7f800000) >>> 23;
47
+ p[pindex++] = ((i & 0x007f0000) >>> 15) | ((input[iindex] & 0x00008000) >> 15);
48
+ }
49
+ const i = input[iindex];
50
+ p[pindex++] = (i & 0x00007f80) >>> 7;
51
+ p[pindex++] = ((i & 0x0000007f) << 1) | ((i & 0x80000000) >>> 31);
52
+ p[pindex++] = (i & 0x7f800000) >>> 23;
53
+ p[pindex++] = (i & 0x007f0000) >>> 15;
54
+ count = (bsize - frames) * 4;
55
+ while (count--)
56
+ p[pindex++] = 0;
57
+ p[pindex - 1] |= 1;
58
+ p[pindex++] = (7 >>> 1) << 6;
59
+ const alacSize = pindex;
60
+ alacData = Buffer.from(p.buffer);
61
+ return alacData.slice(0, alacSize);
62
+ }
@@ -0,0 +1,34 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.AlacEncoderStream = void 0;
4
+ const node_stream_1 = require("node:stream");
5
+ const alac_1 = require("./alac");
6
+ /**
7
+ * Transforms PCM (16-bit LE, stereo, 44.1kHz) into fixed-size ALAC frames.
8
+ * Emits ALAC packets sized per `ALAC_PACKET_SIZE`.
9
+ */
10
+ class AlacEncoderStream extends node_stream_1.Transform {
11
+ buffer = Buffer.alloc(0);
12
+ /** Create a streaming ALAC encoder for PCM input. */
13
+ constructor() {
14
+ super();
15
+ }
16
+ /**
17
+ * Buffer PCM until a full frame is available, then emit ALAC.
18
+ */
19
+ _transform(chunk, _encoding, callback) {
20
+ if (!chunk?.length) {
21
+ callback();
22
+ return;
23
+ }
24
+ this.buffer = (this.buffer.length ? Buffer.concat([this.buffer, chunk]) : chunk);
25
+ while (this.buffer.length >= alac_1.PCM_PACKET_SIZE) {
26
+ const frame = this.buffer.subarray(0, alac_1.PCM_PACKET_SIZE);
27
+ this.buffer = this.buffer.subarray(alac_1.PCM_PACKET_SIZE);
28
+ const alac = (0, alac_1.encodePcmToAlac)(frame);
29
+ this.push(alac);
30
+ }
31
+ callback();
32
+ }
33
+ }
34
+ exports.AlacEncoderStream = AlacEncoderStream;
@@ -0,0 +1,124 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ const node_events_1 = require("node:events");
7
+ const packetPool_1 = __importDefault(require("./packetPool"));
8
+ const WAITING = 0;
9
+ const FILLING = 1;
10
+ const NORMAL = 2;
11
+ const DRAINING = 3;
12
+ const ENDING = 4;
13
+ const ENDED = 5;
14
+ /**
15
+ * Fixed-size circular buffer that smooths incoming PCM/ALAC chunks into fixed packet sizes.
16
+ * Emits status changes for buffering/playing/drain/end to drive UI + sync.
17
+ */
18
+ class CircularBuffer extends node_events_1.EventEmitter {
19
+ packetPool;
20
+ maxSize;
21
+ packetSize;
22
+ writable = true;
23
+ muted = false;
24
+ buffers = [];
25
+ currentSize = 0;
26
+ status = WAITING;
27
+ constructor(packetsInBuffer, packetSize) {
28
+ super();
29
+ this.packetPool = new packetPool_1.default(packetSize);
30
+ this.maxSize = packetsInBuffer * packetSize;
31
+ this.packetSize = packetSize;
32
+ }
33
+ /**
34
+ * Write a PCM/ALAC chunk into the buffer.
35
+ * Returns false when the buffer is full so upstream can throttle.
36
+ */
37
+ write(chunk) {
38
+ this.buffers.push(chunk);
39
+ this.currentSize += chunk.length;
40
+ if (this.status === ENDING || this.status === ENDED) {
41
+ throw new Error('Cannot write in buffer after closing it');
42
+ }
43
+ if (this.status === WAITING) {
44
+ this.emit('status', 'buffering');
45
+ this.status = FILLING;
46
+ }
47
+ if (this.status === FILLING && this.currentSize > this.maxSize / 2) {
48
+ this.status = NORMAL;
49
+ this.emit('status', 'playing');
50
+ }
51
+ if (this.currentSize >= this.maxSize) {
52
+ this.status = DRAINING;
53
+ return false;
54
+ }
55
+ return true;
56
+ }
57
+ /**
58
+ * Read the next fixed-size packet, zero-filling gaps to preserve timing.
59
+ */
60
+ readPacket() {
61
+ const packet = this.packetPool.getPacket();
62
+ if (this.status !== ENDING &&
63
+ this.status !== ENDED &&
64
+ (this.status === FILLING || this.currentSize < this.packetSize)) {
65
+ packet.pcm.fill(0);
66
+ if (this.status !== FILLING && this.status !== WAITING) {
67
+ this.status = FILLING;
68
+ this.emit('status', 'buffering');
69
+ }
70
+ }
71
+ else {
72
+ let offset = 0;
73
+ let remaining = this.packetSize;
74
+ while (remaining > 0) {
75
+ if (this.buffers.length === 0) {
76
+ packet.pcm.fill(0, offset);
77
+ remaining = 0;
78
+ break;
79
+ }
80
+ const first = this.buffers[0];
81
+ if (first.length <= remaining) {
82
+ first.copy(packet.pcm, offset);
83
+ offset += first.length;
84
+ remaining -= first.length;
85
+ this.buffers.shift();
86
+ }
87
+ else {
88
+ first.copy(packet.pcm, offset, 0, remaining);
89
+ this.buffers[0] = first.slice(remaining);
90
+ offset += remaining;
91
+ remaining = 0;
92
+ }
93
+ }
94
+ this.currentSize -= this.packetSize;
95
+ if (this.status === ENDING && this.currentSize <= 0) {
96
+ this.status = ENDED;
97
+ this.currentSize = 0;
98
+ this.emit('status', 'end');
99
+ }
100
+ if (this.status === DRAINING && this.currentSize < this.maxSize / 2) {
101
+ this.status = NORMAL;
102
+ this.emit('drain');
103
+ }
104
+ }
105
+ if (this.muted) {
106
+ packet.pcm.fill(0);
107
+ }
108
+ return packet;
109
+ }
110
+ /** Mark the buffer as ending; drains then emits `end`. */
111
+ end() {
112
+ if (this.status === FILLING) {
113
+ this.emit('status', 'playing');
114
+ }
115
+ this.status = ENDING;
116
+ }
117
+ /** Clear internal buffers and state. */
118
+ reset() {
119
+ this.buffers = [];
120
+ this.currentSize = 0;
121
+ this.status = WAITING;
122
+ }
123
+ }
124
+ exports.default = CircularBuffer;
@@ -0,0 +1,28 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.config = void 0;
4
+ const numUtil_1 = require("./numUtil");
5
+ exports.config = {
6
+ user_agent: 'iTunes/11.3.1 (Windows; Microsoft Windows 10 x64 (Build 19044); x64) (dt:2)',
7
+ udp_default_port: 54621, // preferred starting port in AirTunes v2
8
+ frames_per_packet: 352, // samples per frames in ALAC packets
9
+ channels_per_frame: 2, // always stereo in AirTunes v2
10
+ bits_per_channel: 16, // -> 2 bytes per channel
11
+ pcm_packet_size: 352 * 2 * 2, // frames*channels*bytes
12
+ alac_packet_size: 352 * 2 * 2 + 8, // pcm payload + alac header/footer
13
+ packet_size: 352 * 2 * 2, // active packet size (depends on input codec)
14
+ packets_in_buffer: 260, // ~2.1s of audio (matches MA's ~2000ms buffer)
15
+ coreaudio_min_level: 5, // if CoreAudio's internal buffer drops too much, inject some silence to raise it
16
+ coreaudio_check_period: 2000, // CoreAudio buffer level check period
17
+ coreaudio_preload: 352 * 2 * 2 * 50, // ~0.5s of silence pushed to CoreAudio to avoid draining AudioQueue
18
+ sampling_rate: 44100, // fixed by AirTunes v2
19
+ sync_period: 126, // UDP sync packets are sent to all AirTunes devices regularly
20
+ stream_latency: 200, // audio UDP packets are flushed in bursts periodically
21
+ rtsp_timeout: 2147483647, // RTSP servers are considered gone if no reply is received before the timeout
22
+ rtsp_heartbeat: 15000, // some RTSP (like HomePod) servers requires heartbeat.
23
+ device_magic: (0, numUtil_1.randomInt)(9),
24
+ ntp_epoch: 0x83aa7e80,
25
+ iv_base64: 'ePRBLI0XN5ArFaaz7ncNZw',
26
+ rsa_aeskey_base64: 'VjVbxWcmYgbBbhwBNlCh3K0CMNtWoB844BuiHGUJT51zQS7SDpMnlbBIobsKbfEJ3SCgWHRXjYWf7VQWRYtEcfx7ejA8xDIk5PSBYTvXP5dU2QoGrSBv0leDS6uxlEWuxBq3lIxCxpWO2YswHYKJBt06Uz9P2Fq2hDUwl3qOQ8oXb0OateTKtfXEwHJMprkhsJsGDrIc5W5NJFMAo6zCiM9bGSDeH2nvTlyW6bfI/Q0v0cDGUNeY3ut6fsoafRkfpCwYId+bg3diJh+uzw5htHDyZ2sN+BFYHzEfo8iv4KDxzeya9llqg6fRNQ8d5YjpvTnoeEQ9ye9ivjkBjcAfVw',
27
+ };
28
+ exports.default = exports.config;
@@ -0,0 +1,148 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ const node_net_1 = __importDefault(require("node:net"));
7
+ const HttpMessage = (parseStartLine, writeStartLine) => {
8
+ const instance = {
9
+ parse: () => ({ headers: {} }),
10
+ write: () => Buffer.alloc(0),
11
+ };
12
+ instance.parse = (buffer) => {
13
+ const messageObject = { headers: {} };
14
+ // ...
15
+ let bodyIndex = buffer.indexOf('\r\n\r\n');
16
+ let headerString = buffer.slice(0, bodyIndex).toString();
17
+ let body = buffer.slice(bodyIndex + 4);
18
+ headerString = headerString.replace(/\r\n/g, '\n');
19
+ const lines = headerString.split('\n');
20
+ bodyIndex += 2;
21
+ // ...
22
+ let line = lines.shift();
23
+ if (line) {
24
+ parseStartLine(line, messageObject);
25
+ }
26
+ // ...
27
+ line = lines.shift();
28
+ while (line) {
29
+ const headerName = line.substr(0, line.indexOf(':'));
30
+ const headerValue = line.substr(line.indexOf(':') + 1);
31
+ messageObject.headers[headerName] = headerValue.trim();
32
+ line = lines.shift();
33
+ }
34
+ // ...
35
+ if (messageObject.headers['Content-Length'] && messageObject.headers['Content-Length'] !== '0') {
36
+ messageObject.body = body;
37
+ }
38
+ return messageObject;
39
+ };
40
+ instance.write = (messageObject) => {
41
+ let messageString = writeStartLine(messageObject);
42
+ messageString += '\r\n';
43
+ if (messageObject.body) {
44
+ messageObject.headers['Content-Length'] = String(Buffer.byteLength(messageObject.body));
45
+ }
46
+ for (const header in messageObject.headers) {
47
+ messageString += `${header}: ${messageObject.headers[header]}\r\n`;
48
+ }
49
+ messageString += '\r\n';
50
+ const buffer = Buffer.from(messageString);
51
+ if (!messageObject.body) {
52
+ return buffer;
53
+ }
54
+ return Buffer.concat([buffer, messageObject.body], buffer.length + messageObject.body.length);
55
+ };
56
+ return instance;
57
+ };
58
+ const HttpRequest = () => HttpMessage(() => { }, // currently not parsing requests.
59
+ (messageObject) => `${messageObject.method} ${messageObject.path} HTTP/1.1`);
60
+ const HttpResponse = () => HttpMessage((line, messageObject) => {
61
+ messageObject.statusCode = parseInt(line.split(' ')[1], 10);
62
+ }, () => '');
63
+ // ...
64
+ class HttpClient {
65
+ resolveQueue = [];
66
+ pendingResponse = null;
67
+ socket;
68
+ host;
69
+ // ....
70
+ parseResponse(data) {
71
+ const res = HttpResponse().parse(data);
72
+ if (res.headers['Content-Length'] && Number(res.headers['Content-Length']) > 0) {
73
+ const remaining = Number(res.headers['Content-Length']) - (res.body?.byteLength ?? 0);
74
+ if (remaining > 0) {
75
+ // not all data for this response's corresponding request was read. Create a pending response object
76
+ // to use for further reads.
77
+ this.pendingResponse = {
78
+ res,
79
+ remaining
80
+ };
81
+ }
82
+ }
83
+ if (!this.pendingResponse) {
84
+ const rr = this.resolveQueue.shift();
85
+ if (!rr)
86
+ return;
87
+ res.statusCode === 200
88
+ ? rr.resolve(res)
89
+ : rr.resolve(null);
90
+ }
91
+ }
92
+ // ...
93
+ connect(host, port = 80) {
94
+ this.host = host;
95
+ return new Promise(resolve => {
96
+ this.socket = node_net_1.default.connect({
97
+ host,
98
+ port
99
+ }, resolve);
100
+ this.socket.on('data', data => {
101
+ if (!this.pendingResponse) {
102
+ // there is no response pending, parse the data.
103
+ this.parseResponse(data);
104
+ }
105
+ else {
106
+ // incoming data for the pending response.
107
+ const existing = this.pendingResponse.res.body ?? Buffer.alloc(0);
108
+ this.pendingResponse.res.body = Buffer.concat([existing, data], data.byteLength + existing.byteLength);
109
+ this.pendingResponse.remaining -= data.byteLength;
110
+ if (this.pendingResponse.remaining === 0) {
111
+ // all remaining data for the pending response has been read; resolve the promise for the
112
+ // corresponding request.
113
+ const rr = this.resolveQueue.shift();
114
+ if (!rr) {
115
+ this.pendingResponse = null;
116
+ return;
117
+ }
118
+ this.pendingResponse.res.statusCode === 200
119
+ ? rr.resolve(this.pendingResponse.res)
120
+ : rr.reject(new Error(`HTTP status: ${this.pendingResponse.res.statusCode}`));
121
+ this.pendingResponse = null;
122
+ }
123
+ }
124
+ });
125
+ });
126
+ }
127
+ request(method, path, headers, body) {
128
+ headers = headers || {};
129
+ // headers['Host'] = `${this.host}:${this.socket.remotePort}`;
130
+ const data = HttpRequest().write({
131
+ method,
132
+ path,
133
+ headers,
134
+ body
135
+ });
136
+ // ...
137
+ return new Promise((resolve, reject) => {
138
+ this.resolveQueue.push({ resolve, reject });
139
+ this.socket?.write(data);
140
+ });
141
+ }
142
+ close() {
143
+ this.socket?.end();
144
+ }
145
+ }
146
+ // ...
147
+ const createHttpClient = () => new HttpClient();
148
+ exports.default = createHttpClient;