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
package/README.md ADDED
@@ -0,0 +1,85 @@
1
+ # lox-airplay-sender
2
+
3
+ AirPlay sender (RAOP/AirPlay 1 + AirPlay 2 auth) refactored from node_airtunes2 into a modern, typed TypeScript module. It owns the RTSP/UDP pipeline, ALAC encoding, and metadata handling with no native dependencies.
4
+
5
+ ## Requirements
6
+ - Node.js 18+
7
+ - PCM input: 16-bit little-endian, stereo, 44.1kHz (ALAC encoding is handled internally)
8
+
9
+ ## Installation
10
+ ```bash
11
+ npm install lox-airplay-sender
12
+ ```
13
+
14
+ ## Quick start
15
+ ```ts
16
+ import { start } from "lox-airplay-sender";
17
+
18
+ const sender = start(
19
+ {
20
+ host: "192.168.1.162",
21
+ port: 7000, // defaults to 5000
22
+ airplay2: true, // set to true for AirPlay 2 devices
23
+ log: (level, msg, data) => console.log(`[${level}]`, msg, data),
24
+ },
25
+ (event) => console.log("event", event)
26
+ );
27
+
28
+ sender.setMetadata({
29
+ title: "Track",
30
+ artist: "Artist",
31
+ album: "Album",
32
+ coverUrl: "https://example.com/cover.jpg",
33
+ durationMs: 180_000,
34
+ elapsedMs: 0,
35
+ });
36
+
37
+ // Write raw PCM chunks as they arrive
38
+ sender.sendPcm(Buffer.from(/* pcm data */));
39
+ ```
40
+
41
+ ## API
42
+ ### `start(options, onEvent?) => LoxAirplaySender`
43
+ Creates and starts a sender for one AirPlay device. Returns the instance so you can call methods directly.
44
+
45
+ **Options**
46
+ - `host` (string, required) AirPlay device hostname/IP.
47
+ - `port` (number) RAOP port, default 5000.
48
+ - `name` (string) Sender name shown on receiver.
49
+ - `password` (string | null) AirPlay 1 password.
50
+ - `volume` (number) Initial volume (0–100), default 50.
51
+ - `mode` (number) RAOP mode; defaults to 2 when `airplay2` is true, else 0.
52
+ - `txt` (string[]) Custom TXT records.
53
+ - `forceAlac` (boolean) Encode ALAC even when input is ALAC; default true.
54
+ - `alacEncoding` (boolean) Enable ALAC encoding; default true.
55
+ - `inputCodec` (`"pcm"` | `"alac"`) Defaults to `"pcm"`.
56
+ - `airplay2` (boolean) Enable AirPlay 2 auth/flags; default false.
57
+ - `startTimeMs` (number) Unix ms to align playback across devices.
58
+ - `debug` (boolean) Verbose logging from the transport stack.
59
+ - `log` `(level, message, data?) => void` Hook for library logs.
60
+
61
+ **Events** (sent to `onEvent` callback)
62
+ - `device`: `{ event: "device", message: status, detail: { key, desc } }`
63
+ - `buffer`: `{ event: "buffer", message: status }` where status is `buffering|playing|drain|end`
64
+ - `error`: `{ event: "error", message }`
65
+
66
+ ### `LoxAirplaySender` methods
67
+ - `sendPcm(chunk: Buffer)`: Push raw PCM audio. If `inputCodec` is `"alac"` you can push ALAC frames.
68
+ - `pipeStream(stream: Readable)`: Convenience to pipe a Node stream into `sendPcm`; auto-stops on `end`/`error`.
69
+ - `setMetadata({ title, artist, album, cover, coverUrl, elapsedMs, durationMs })`: Updates track info, cover art (Buffer or URL), and progress. Cover URLs are fetched with a short timeout and deduplicated.
70
+ - `setTrackInfo(title, artist?, album?)`: Direct track update.
71
+ - `setArtwork(buffer, mime?)`: Send cover art immediately.
72
+ - `setProgress(elapsedSec, durationSec)`: Manual progress update.
73
+ - `setVolume(volume)`: Adjust volume (0–100).
74
+ - `setPasscode(passcode)`: Provide a passcode when the receiver requests it.
75
+ - `stop()`: Stop the sender, close sockets/streams, and clear state.
76
+
77
+ ## Sync playback
78
+ Use `startTimeMs` to align multiple senders to the same start clock (Unix ms). Feed each sender PCM in lockstep; they will start at the scheduled time.
79
+
80
+ ## Development
81
+ ```bash
82
+ npm install
83
+ npm run build
84
+ npm run clean # remove dist
85
+ ```
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,8 @@
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 number_1 = __importDefault(require("../homekit/number"));
7
+ const u = number_1.default.UInt16toBufferBE(287);
8
+ console.log(Buffer.concat([u.slice(1), u.slice(0, 1)]));
@@ -0,0 +1,16 @@
1
+ import httpClientFactory from '../utils/http';
2
+ declare class ATV {
3
+ addr: string;
4
+ port: number;
5
+ httpClient: ReturnType<typeof httpClientFactory>;
6
+ auth_secret: string | null;
7
+ constructor(addr: string, port?: number);
8
+ auth(configFilePath: string, authenticator: () => Promise<string>): Promise<import("../utils/http").MessageObject | null>;
9
+ authSecret(): string | null;
10
+ authSimple(authenticator: () => Promise<string>): Promise<void>;
11
+ verifySimple(secret: string): Promise<import("../utils/http").MessageObject | null>;
12
+ play(videoUrl: string): Promise<import("../utils/http").MessageObject | null>;
13
+ stop(): Promise<import("../utils/http").MessageObject | null>;
14
+ close(): void;
15
+ }
16
+ export default ATV;
@@ -0,0 +1,215 @@
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 crypto_1 = __importDefault(require("crypto"));
7
+ const fs_1 = __importDefault(require("fs"));
8
+ const bplist_creator_1 = __importDefault(require("bplist-creator"));
9
+ const bplist_parser_1 = __importDefault(require("bplist-parser"));
10
+ const srp_1 = __importDefault(require("./srp"));
11
+ const atvAuthenticator_1 = __importDefault(require("./atvAuthenticator"));
12
+ const http_1 = __importDefault(require("../utils/http"));
13
+ // ...
14
+ // Configuration.
15
+ const loadConfig = (configFilePath) => !fs_1.default.existsSync(configFilePath) ? null : JSON.parse(fs_1.default.readFileSync(configFilePath, 'utf8'));
16
+ const saveConfig = (configFilePath, config) => fs_1.default.writeFileSync(configFilePath, JSON.stringify(config, null, '\t'));
17
+ // ...
18
+ class ATV {
19
+ addr;
20
+ port;
21
+ httpClient;
22
+ auth_secret;
23
+ constructor(addr, port) {
24
+ this.addr = addr;
25
+ this.port = port || 7000;
26
+ this.httpClient = (0, http_1.default)();
27
+ this.auth_secret = null;
28
+ }
29
+ // ...
30
+ auth(configFilePath, authenticator) {
31
+ async function auth(owner) {
32
+ await owner.httpClient.connect(owner.addr, owner.port);
33
+ let conf = loadConfig(configFilePath);
34
+ const authSecret = conf && typeof conf['auth_secret'] === 'string' ? conf['auth_secret'] : null;
35
+ if (!authSecret) {
36
+ // a pairing does not exist and must be performed.
37
+ // ...
38
+ // SRP parameters.
39
+ const srp = new srp_1.default(2048);
40
+ const I = '366B4165DD64AD3A';
41
+ let P;
42
+ let s;
43
+ let B;
44
+ let a;
45
+ let A;
46
+ let M1;
47
+ await owner.httpClient.request('POST', '/pair-pin-start')
48
+ .then(() => authenticator())
49
+ .then(pin => {
50
+ P = pin;
51
+ return owner.httpClient.request('POST', '/pair-setup-pin', {
52
+ 'Content-Type': 'application/x-apple-binary-plist'
53
+ }, (0, bplist_creator_1.default)({
54
+ user: '366B4165DD64AD3A',
55
+ method: 'pin'
56
+ }));
57
+ })
58
+ .then((res) => {
59
+ const { pk, salt } = bplist_parser_1.default.parseBuffer(res.body)[0];
60
+ s = salt.toString('hex');
61
+ B = pk.toString('hex');
62
+ // SRP: Generate random auth_secret, 'a'; if pairing is successful, it'll be utilized in
63
+ // subsequent session authentication(s).
64
+ a = crypto_1.default.randomBytes(32).toString('hex');
65
+ // SRP: Compute A and M1.
66
+ A = srp.A(a);
67
+ M1 = srp.M1(I, P, s, a, B);
68
+ return owner.httpClient.request('POST', '/pair-setup-pin', {
69
+ 'Content-Type': 'application/x-apple-binary-plist'
70
+ }, (0, bplist_creator_1.default)({
71
+ pk: Buffer.from(A, 'hex'),
72
+ proof: Buffer.from(M1, 'hex')
73
+ }));
74
+ })
75
+ .then(() => {
76
+ // confirm the auth secret (a).
77
+ const { epk, authTag } = atvAuthenticator_1.default.confirm(a, srp.K(I, P, s, a, B));
78
+ // complete pair-setup-pin by registering the auth secret with the target device.
79
+ return owner.httpClient.request('POST', '/pair-setup-pin', {
80
+ 'Content-Type': 'application/x-apple-binary-plist'
81
+ }, (0, bplist_creator_1.default)({
82
+ epk: Buffer.from(epk, 'hex'),
83
+ authTag: Buffer.from(authTag, 'hex')
84
+ }));
85
+ })
86
+ .then(() => {
87
+ // save the auth secret for subsequent session authentication(s).
88
+ if (!conf) {
89
+ conf = {};
90
+ }
91
+ conf['auth_secret'] = a;
92
+ saveConfig(configFilePath, conf);
93
+ });
94
+ }
95
+ // ...
96
+ // Authenticate session with the target device using existing pairing information.
97
+ const verifier = atvAuthenticator_1.default.verifier(conf?.['auth_secret'] ?? '');
98
+ return owner.httpClient.request('POST', '/pair-verify', {
99
+ 'Content-Type': 'application/octet-stream'
100
+ }, verifier.verifierBody)
101
+ .then((res) => {
102
+ const atv_pub = res.body.slice(0, 32).toString('hex');
103
+ const atv_data = res.body.slice(32).toString('hex');
104
+ const shared = atvAuthenticator_1.default.shared(verifier.v_pri, atv_pub);
105
+ const signed = atvAuthenticator_1.default.signed(conf?.['auth_secret'] ?? '', verifier.v_pub, atv_pub);
106
+ const signature = Buffer.from(Buffer.from([0x00, 0x00, 0x00, 0x00]).toString('hex') +
107
+ atvAuthenticator_1.default.signature(shared, atv_data, signed), 'hex');
108
+ return owner.httpClient.request('POST', '/pair-verify', {
109
+ 'Content-Type': 'application/octet-stream'
110
+ }, signature);
111
+ });
112
+ }
113
+ return auth(this);
114
+ }
115
+ authSecret() {
116
+ return this.auth_secret;
117
+ }
118
+ authSimple(authenticator) {
119
+ async function auth(owner) {
120
+ await owner.httpClient.connect(owner.addr, owner.port);
121
+ const conf = null;
122
+ if (!conf || !conf['auth_secret']) {
123
+ // a pairing does not exist and must be performed.
124
+ // ...
125
+ // SRP parameters.
126
+ const srp = new srp_1.default(2048);
127
+ const I = '366B4165DD64AD3A';
128
+ let P;
129
+ let s;
130
+ let B;
131
+ let a;
132
+ let A;
133
+ let M1;
134
+ await owner.httpClient.request('POST', '/pair-pin-start')
135
+ .then(() => authenticator())
136
+ .then(pin => {
137
+ P = pin;
138
+ return owner.httpClient.request('POST', '/pair-setup-pin', {
139
+ 'Content-Type': 'application/x-apple-binary-plist'
140
+ }, (0, bplist_creator_1.default)({
141
+ user: '366B4165DD64AD3A',
142
+ method: 'pin'
143
+ }));
144
+ })
145
+ .then((res) => {
146
+ const { pk, salt } = bplist_parser_1.default.parseBuffer(res.body)[0];
147
+ s = salt.toString('hex');
148
+ B = pk.toString('hex');
149
+ // SRP: Generate random auth_secret, 'a'; if pairing is successful, it'll be utilized in
150
+ // subsequent session authentication(s).
151
+ a = crypto_1.default.randomBytes(32).toString('hex');
152
+ // SRP: Compute A and M1.
153
+ A = srp.A(a);
154
+ M1 = srp.M1(I, P, s, a, B);
155
+ return owner.httpClient.request('POST', '/pair-setup-pin', {
156
+ 'Content-Type': 'application/x-apple-binary-plist'
157
+ }, (0, bplist_creator_1.default)({
158
+ pk: Buffer.from(A, 'hex'),
159
+ proof: Buffer.from(M1, 'hex')
160
+ }));
161
+ }).then(() => {
162
+ // confirm the auth secret (a).
163
+ const { epk, authTag } = atvAuthenticator_1.default.confirm(a, srp.K(I, P, s, a, B));
164
+ // complete pair-setup-pin by registering the auth secret with the target device.
165
+ return owner.httpClient.request('POST', '/pair-setup-pin', {
166
+ 'Content-Type': 'application/x-apple-binary-plist'
167
+ }, (0, bplist_creator_1.default)({
168
+ epk: Buffer.from(epk, 'hex'),
169
+ authTag: Buffer.from(authTag, 'hex')
170
+ }));
171
+ })
172
+ .then(() => {
173
+ // save the auth secret for subsequent session authentication(s).
174
+ owner.auth_secret = a;
175
+ });
176
+ }
177
+ }
178
+ return auth(this);
179
+ }
180
+ verifySimple(secret) {
181
+ // ...
182
+ // Authenticate session with the target device using existing pairing information.
183
+ const verifier = atvAuthenticator_1.default.verifier(secret);
184
+ return this.httpClient.request('POST', '/pair-verify', {
185
+ 'Content-Type': 'application/octet-stream',
186
+ }, verifier.verifierBody)
187
+ .then((res) => {
188
+ const atv_pub = res.body.slice(0, 32).toString('hex');
189
+ const atv_data = res.body.slice(32).toString('hex');
190
+ const shared = atvAuthenticator_1.default.shared(verifier.v_pri, atv_pub);
191
+ const signed = atvAuthenticator_1.default.signed(secret, verifier.v_pub, atv_pub);
192
+ const signature = Buffer.from(Buffer.from([0x00, 0x00, 0x00, 0x00]).toString('hex') +
193
+ atvAuthenticator_1.default.signature(shared, atv_data, signed), 'hex');
194
+ return this.httpClient.request('POST', '/pair-verify', {
195
+ 'Content-Type': 'application/octet-stream',
196
+ }, signature);
197
+ });
198
+ }
199
+ play(videoUrl) {
200
+ return this.httpClient.request('POST', '/play', {
201
+ 'Content-Type': 'application/x-apple-binary-plist'
202
+ }, (0, bplist_creator_1.default)({
203
+ 'Content-Location': videoUrl,
204
+ 'Start-Location': 0
205
+ }));
206
+ }
207
+ stop() {
208
+ return this.httpClient.request('POST', '/stop');
209
+ }
210
+ close() {
211
+ this.httpClient.close();
212
+ }
213
+ }
214
+ // ...
215
+ exports.default = ATV;
@@ -0,0 +1,30 @@
1
+ declare function pair_setup_aes_key(K: string): string;
2
+ declare function pair_setup_aes_iv(K: string): string;
3
+ declare function pair_verify_aes_key(shared: string): string;
4
+ declare function pair_verify_aes_iv(shared: string): string;
5
+ declare function a_pub(a: string): string;
6
+ declare function confirm(a: string, K: string): {
7
+ epk: string;
8
+ authTag: string;
9
+ };
10
+ declare function verifier(a: string): {
11
+ verifierBody: Buffer;
12
+ v_pri: string;
13
+ v_pub: string;
14
+ };
15
+ declare function shared(v_pri: string, atv_pub: string): string;
16
+ declare function signed(a: string, v_pub: string, atv_pub: string): string;
17
+ declare function signature(shared: string, atv_data: string, signed: string): string;
18
+ declare const _default: {
19
+ pair_setup_aes_key: typeof pair_setup_aes_key;
20
+ pair_setup_aes_iv: typeof pair_setup_aes_iv;
21
+ pair_verify_aes_key: typeof pair_verify_aes_key;
22
+ pair_verify_aes_iv: typeof pair_verify_aes_iv;
23
+ a_pub: typeof a_pub;
24
+ confirm: typeof confirm;
25
+ verifier: typeof verifier;
26
+ shared: typeof shared;
27
+ signed: typeof signed;
28
+ signature: typeof signature;
29
+ };
30
+ export default _default;
@@ -0,0 +1,134 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ var __importDefault = (this && this.__importDefault) || function (mod) {
36
+ return (mod && mod.__esModule) ? mod : { "default": mod };
37
+ };
38
+ Object.defineProperty(exports, "__esModule", { value: true });
39
+ const elliptic = require('elliptic');
40
+ const crypto_1 = __importDefault(require("crypto"));
41
+ const ed = __importStar(require("@noble/ed25519"));
42
+ const util_1 = require("../utils/util");
43
+ // ...
44
+ // Note: All functions expect parameters to be hex strings.
45
+ function pair_setup_aes_key(K) {
46
+ return crypto_1.default.createHash('sha512')
47
+ .update('Pair-Setup-AES-Key')
48
+ .update((0, util_1.hexString2ArrayBuffer)(K))
49
+ .digest('hex')
50
+ .substring(0, 32);
51
+ }
52
+ function pair_setup_aes_iv(K) {
53
+ let ab = crypto_1.default.createHash('sha512')
54
+ .update('Pair-Setup-AES-IV')
55
+ .update((0, util_1.hexString2ArrayBuffer)(K))
56
+ .digest();
57
+ ab = ab.slice(0, 16);
58
+ ab[ab.length - 1] += 0x01;
59
+ return (0, util_1.buf2hex)(ab);
60
+ }
61
+ function pair_verify_aes_key(shared) {
62
+ return (0, util_1.buf2hex)(crypto_1.default.createHash('sha512')
63
+ .update('Pair-Verify-AES-Key')
64
+ .update((0, util_1.hexString2ArrayBuffer)(shared))
65
+ .digest()
66
+ .slice(0, 16));
67
+ }
68
+ function pair_verify_aes_iv(shared) {
69
+ return (0, util_1.buf2hex)(crypto_1.default.createHash('sha512')
70
+ .update('Pair-Verify-AES-IV')
71
+ .update((0, util_1.hexString2ArrayBuffer)(shared))
72
+ .digest()
73
+ .slice(0, 16));
74
+ }
75
+ // ...
76
+ // Public.
77
+ function a_pub(a) {
78
+ return elliptic.utils.toHex(new elliptic.eddsa('ed25519').keyFromSecret(a).getPublic());
79
+ }
80
+ function confirm(a, K) {
81
+ const key = pair_setup_aes_key(K);
82
+ const iv = pair_setup_aes_iv(K);
83
+ const cipher = crypto_1.default.createCipheriv('aes-128-gcm', (0, util_1.hexString2ArrayBuffer)(key), (0, util_1.hexString2ArrayBuffer)(iv));
84
+ const encrypted = Buffer.concat([
85
+ cipher.update((0, util_1.hexString2ArrayBuffer)(a_pub(a))),
86
+ cipher.final(),
87
+ ]);
88
+ return {
89
+ epk: encrypted.toString('hex'),
90
+ authTag: (0, util_1.buf2hex)(cipher.getAuthTag()),
91
+ };
92
+ }
93
+ function verifier(a) {
94
+ const privateKey = Buffer.from(ed.utils.randomPrivateKey());
95
+ const publicKey = Buffer.from(ed.curve25519.scalarMultBase(privateKey));
96
+ const v_pri = (0, util_1.buf2hex)(privateKey);
97
+ const v_pub = (0, util_1.buf2hex)(publicKey);
98
+ const header = Buffer.from([0x01, 0x00, 0x00, 0x00]);
99
+ const a_pub_buf = Buffer.from(a_pub(a), 'hex');
100
+ return {
101
+ verifierBody: Buffer.concat([header, publicKey, a_pub_buf], header.byteLength + publicKey.byteLength + a_pub_buf.byteLength),
102
+ v_pri,
103
+ v_pub
104
+ };
105
+ }
106
+ function shared(v_pri, atv_pub) {
107
+ return (0, util_1.buf2hex)(Buffer.from(ed.curve25519.scalarMult((0, util_1.hexString2ArrayBuffer)(v_pri), (0, util_1.hexString2ArrayBuffer)(atv_pub))));
108
+ }
109
+ function signed(a, v_pub, atv_pub) {
110
+ const key = new elliptic.eddsa('ed25519').keyFromSecret(a);
111
+ return key.sign(v_pub + atv_pub).toHex();
112
+ }
113
+ function signature(shared, atv_data, signed) {
114
+ const cipher = crypto_1.default.createCipheriv('aes-128-ctr', (0, util_1.hexString2ArrayBuffer)(pair_verify_aes_key(shared)), (0, util_1.hexString2ArrayBuffer)(pair_verify_aes_iv(shared)));
115
+ // discard the result of encrypting atv_data.
116
+ cipher.update((0, util_1.hexString2ArrayBuffer)(atv_data));
117
+ const encrypted = Buffer.concat([
118
+ cipher.update(Buffer.from(signed, 'hex')),
119
+ cipher.final(),
120
+ ]);
121
+ return encrypted.toString('hex');
122
+ }
123
+ exports.default = {
124
+ pair_setup_aes_key,
125
+ pair_setup_aes_iv,
126
+ pair_verify_aes_key,
127
+ pair_verify_aes_iv,
128
+ a_pub,
129
+ confirm,
130
+ verifier,
131
+ shared,
132
+ signed,
133
+ signature,
134
+ };
@@ -0,0 +1,30 @@
1
+ import { EventEmitter } from 'node:events';
2
+ import type CircularBuffer from '../utils/circularBuffer';
3
+ type DevicesEmitter = EventEmitter & {
4
+ on(event: 'airtunes_devices', listener: (hasAirTunes: boolean) => void): DevicesEmitter;
5
+ on(event: 'need_sync', listener: () => void): DevicesEmitter;
6
+ };
7
+ /**
8
+ * Generates RTP timestamps and sequence, pulling PCM/ALAC packets from a circular buffer.
9
+ * Emits `packet` events for devices and sync requests (`need_sync`) at intervals.
10
+ */
11
+ export default class AudioOut extends EventEmitter {
12
+ private lastSeq;
13
+ private hasAirTunes;
14
+ private rtpTimeRef;
15
+ private startTimeMs?;
16
+ private latencyFrames;
17
+ private latencyApplied;
18
+ /**
19
+ * Begin pulling from the buffer and emitting packets at the configured cadence.
20
+ * @param devices Device manager for sync events.
21
+ * @param circularBuffer PCM/ALAC buffer.
22
+ * @param startTimeMs Optional unix ms to align playback.
23
+ */
24
+ init(devices: DevicesEmitter, circularBuffer: CircularBuffer, startTimeMs?: number): void;
25
+ /**
26
+ * Apply latency (in audio frames) when aligning start time.
27
+ */
28
+ setLatencyFrames(latencyFrames: number): void;
29
+ }
30
+ export {};
@@ -0,0 +1,80 @@
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 config_1 = __importDefault(require("../utils/config"));
8
+ const numUtil_1 = require("../utils/numUtil");
9
+ const SEQ_NUM_WRAP = Math.pow(2, 16);
10
+ /**
11
+ * Generates RTP timestamps and sequence, pulling PCM/ALAC packets from a circular buffer.
12
+ * Emits `packet` events for devices and sync requests (`need_sync`) at intervals.
13
+ */
14
+ class AudioOut extends node_events_1.EventEmitter {
15
+ lastSeq = -1;
16
+ hasAirTunes = false;
17
+ rtpTimeRef = Date.now();
18
+ startTimeMs;
19
+ latencyFrames = 0;
20
+ latencyApplied = false;
21
+ /**
22
+ * Begin pulling from the buffer and emitting packets at the configured cadence.
23
+ * @param devices Device manager for sync events.
24
+ * @param circularBuffer PCM/ALAC buffer.
25
+ * @param startTimeMs Optional unix ms to align playback.
26
+ */
27
+ init(devices, circularBuffer, startTimeMs) {
28
+ this.startTimeMs =
29
+ typeof startTimeMs === 'number' && Number.isFinite(startTimeMs)
30
+ ? startTimeMs
31
+ : undefined;
32
+ this.rtpTimeRef = this.startTimeMs ?? Date.now();
33
+ devices.on('airtunes_devices', (hasAirTunes) => {
34
+ this.hasAirTunes = hasAirTunes;
35
+ });
36
+ devices.on('need_sync', () => {
37
+ this.emit('need_sync', this.lastSeq);
38
+ });
39
+ const sendPacket = (seq) => {
40
+ const packet = circularBuffer.readPacket();
41
+ packet.seq = seq % SEQ_NUM_WRAP;
42
+ packet.timestamp = (0, numUtil_1.low32)(seq * config_1.default.frames_per_packet + 2 * config_1.default.sampling_rate);
43
+ if (this.hasAirTunes && seq % config_1.default.sync_period === 0) {
44
+ this.emit('need_sync', seq);
45
+ }
46
+ this.emit('packet', packet);
47
+ packet.release();
48
+ };
49
+ const syncAudio = () => {
50
+ const elapsed = Date.now() - this.rtpTimeRef;
51
+ if (elapsed < 0) {
52
+ setTimeout(syncAudio, Math.min(config_1.default.stream_latency, Math.abs(elapsed)));
53
+ return;
54
+ }
55
+ const currentSeq = Math.floor((elapsed * config_1.default.sampling_rate) / (config_1.default.frames_per_packet * 1000));
56
+ for (let i = this.lastSeq + 1; i <= currentSeq; i += 1) {
57
+ sendPacket(i);
58
+ }
59
+ this.lastSeq = currentSeq;
60
+ setTimeout(syncAudio, config_1.default.stream_latency);
61
+ };
62
+ syncAudio();
63
+ }
64
+ /**
65
+ * Apply latency (in audio frames) when aligning start time.
66
+ */
67
+ setLatencyFrames(latencyFrames) {
68
+ if (!Number.isFinite(latencyFrames) || latencyFrames <= 0) {
69
+ return;
70
+ }
71
+ this.latencyFrames = latencyFrames;
72
+ if (this.startTimeMs === undefined || this.latencyApplied) {
73
+ return;
74
+ }
75
+ const latencyMs = (this.latencyFrames / config_1.default.sampling_rate) * 1000;
76
+ this.rtpTimeRef = this.startTimeMs - latencyMs;
77
+ this.latencyApplied = true;
78
+ }
79
+ }
80
+ exports.default = AudioOut;
@@ -0,0 +1,72 @@
1
+ import dgram from 'node:dgram';
2
+ import { EventEmitter } from 'node:events';
3
+ import UDPServers from './udpServers';
4
+ type AnyObject = Record<string, unknown>;
5
+ type RTSPClient = {
6
+ on: (event: string, cb: (...args: any[]) => void) => void;
7
+ once: (event: string, cb: (...args: any[]) => void) => void;
8
+ startHandshake: (udpServers: any, host: string, port: number) => void;
9
+ teardown: () => void;
10
+ setVolume: (volume: number, callback?: (err?: unknown) => void) => void;
11
+ setTrackInfo: (name: string, artist?: string, album?: string, callback?: (err?: unknown) => void) => void;
12
+ setProgress: (progress: number, duration: number, callback?: (err?: unknown) => void) => void;
13
+ setArtwork: (art: Buffer, contentType?: string, callback?: (err?: unknown) => void) => void;
14
+ setPasscode: (password: string) => void;
15
+ };
16
+ type Packet = {
17
+ seq: number;
18
+ pcm: Buffer;
19
+ timestamp: number;
20
+ };
21
+ type LogFn = (level: 'debug' | 'info' | 'warn' | 'error', message: string, data?: unknown) => void;
22
+ declare class BufferWithNames {
23
+ private readonly size;
24
+ private readonly buffer;
25
+ constructor(size: number);
26
+ add(name: string, item: any): void;
27
+ getLatestNamed(name: string): any;
28
+ }
29
+ type AirTunesDeviceInstance = EventEmitter & {
30
+ audioPacketHistory: BufferWithNames | null;
31
+ udpServers: UDPServers;
32
+ audioOut: EventEmitter;
33
+ type: string;
34
+ options: AnyObject;
35
+ host: string;
36
+ port: number;
37
+ key: string;
38
+ mode: number;
39
+ forceAlac: boolean;
40
+ statusflags: string[];
41
+ alacEncoding: boolean;
42
+ inputCodec: 'pcm' | 'alac';
43
+ airplay2: boolean;
44
+ txt: string[];
45
+ borkedshp: boolean;
46
+ needPassword: boolean;
47
+ needPin: boolean;
48
+ transient: boolean;
49
+ features: string[];
50
+ rtsp: RTSPClient | null;
51
+ audioCallback: ((packet: Packet) => void) | null;
52
+ encoder: any[];
53
+ credentials: any;
54
+ audioSocket: dgram.Socket | null;
55
+ status: string;
56
+ serverPort: number;
57
+ controlPort: number;
58
+ timingPort: number;
59
+ audioLatency: number;
60
+ requireEncryption: boolean;
61
+ log?: LogFn;
62
+ logLine?: (...args: any[]) => void;
63
+ doHandshake: () => void;
64
+ relayAudio: () => void;
65
+ cleanup: () => void;
66
+ };
67
+ /**
68
+ * Construct a RAOP/AirPlay device handler.
69
+ * Accepts discovery TXT/flags and wires RTSP handshake to UDP audio.
70
+ */
71
+ declare function AirTunesDevice(this: AirTunesDeviceInstance, host: string | null, audioOut: EventEmitter, options: AnyObject, mode?: number, txt?: string[] | string): void;
72
+ export default AirTunesDevice;