@ciphore/radiocli 0.2.4 → 0.2.5

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 (134) hide show
  1. package/CHANGELOG.md +70 -2
  2. package/CONTRIBUTING.md +10 -0
  3. package/README.md +108 -253
  4. package/SECURITY.md +23 -1
  5. package/dist/agent/alarm-service.js +10 -7
  6. package/dist/agent/cli.js +16 -1
  7. package/dist/agent/command-schema.js +47 -0
  8. package/dist/agent/headless-host.js +226 -81
  9. package/dist/agent/launcher.js +2 -1
  10. package/dist/agent/mcp-server.js +10 -5
  11. package/dist/agent/service.js +75 -40
  12. package/dist/agent/session.js +214 -24
  13. package/dist/agent/tui-session.js +213 -0
  14. package/dist/alarms/cli.js +19 -3
  15. package/dist/alarms/occurrence-cache.js +25 -0
  16. package/dist/alarms/runner.js +87 -23
  17. package/dist/alarms/schedule.js +29 -1
  18. package/dist/alarms/scheduler.js +11 -3
  19. package/dist/alarms/system-volume-linux.js +160 -0
  20. package/dist/alarms/system-volume-ownership.js +76 -8
  21. package/dist/alarms/system-volume.js +44 -23
  22. package/dist/cli/arguments.js +212 -0
  23. package/dist/cli/completion.js +44 -0
  24. package/dist/cli/doctor.js +156 -0
  25. package/dist/cli/interactive.js +79 -0
  26. package/dist/cli/listener.js +227 -0
  27. package/dist/cli/output.js +121 -0
  28. package/dist/cli/render-tui.js +34 -0
  29. package/dist/cli.js +178 -199
  30. package/dist/platform/capabilities.js +8 -1
  31. package/dist/platform/desktop-media.js +61 -0
  32. package/dist/platform/diagnostics.js +273 -0
  33. package/dist/platform/json-worker.js +111 -0
  34. package/dist/platform/launch-command.js +9 -6
  35. package/dist/platform/mpris.js +356 -0
  36. package/dist/platform/network.js +69 -1
  37. package/dist/platform/omarchy.js +147 -0
  38. package/dist/platform/packages.js +7 -1
  39. package/dist/platform/runtime.js +1 -1
  40. package/dist/platform/terminals.js +24 -4
  41. package/dist/player/airplay-discovery.js +144 -61
  42. package/dist/player/airplay-policy.js +15 -0
  43. package/dist/player/airplay-process.js +63 -0
  44. package/dist/player/airplay-readiness.js +42 -0
  45. package/dist/player/airplay-sender-health.js +49 -49
  46. package/dist/player/airplay-sender-package.js +5 -0
  47. package/dist/player/airplay-sender-patch.js +102 -4
  48. package/dist/player/airplay-setup.js +51 -0
  49. package/dist/player/airplay-worker.js +97 -41
  50. package/dist/player/backend-install.js +9 -6
  51. package/dist/player/ffmpeg-input.js +37 -0
  52. package/dist/player/mpv-desktop-media.js +194 -0
  53. package/dist/player/mpv-ipc-client.js +51 -2
  54. package/dist/player/player-controller.js +390 -73
  55. package/dist/player/radio-engine.js +282 -0
  56. package/dist/playlists/playlist.js +18 -16
  57. package/dist/providers/cache.js +291 -75
  58. package/dist/providers/discovery-session.js +111 -0
  59. package/dist/providers/provider-manager.js +106 -28
  60. package/dist/providers/radio-browser.js +272 -41
  61. package/dist/providers/radio-garden.js +4 -2
  62. package/dist/setup-playback.js +152 -0
  63. package/dist/setup.js +88 -21
  64. package/dist/storage/file-lock.js +113 -0
  65. package/dist/storage/read-settings.js +33 -0
  66. package/dist/storage/replace-file.js +20 -0
  67. package/dist/storage/repository.js +247 -0
  68. package/dist/storage/station-identity.js +21 -0
  69. package/dist/storage/station-schema.js +27 -0
  70. package/dist/storage/store.js +249 -76
  71. package/dist/ui/AdaptiveContent.js +18 -10
  72. package/dist/ui/App.js +708 -454
  73. package/dist/ui/AppContent.js +5 -5
  74. package/dist/ui/airplay-settings.js +12 -5
  75. package/dist/ui/bidi-ranges.js +45 -0
  76. package/dist/ui/components/AdaptiveMarquee.js +16 -9
  77. package/dist/ui/format.js +37 -4
  78. package/dist/ui/help-content.js +5 -2
  79. package/dist/ui/rtl-output.js +137 -0
  80. package/dist/ui/screen-items.js +10 -4
  81. package/dist/ui/screens/AirPlaySettingsScreen.js +3 -3
  82. package/dist/ui/screens/AlarmsScreen.js +5 -2
  83. package/dist/ui/screens/HelpScreen.js +3 -3
  84. package/dist/ui/screens/SettingsScreen.js +16 -16
  85. package/dist/ui/screens/StatsScreen.js +1 -1
  86. package/dist/ui/text-direction.js +9 -0
  87. package/dist/ui/use-alarm-tui.js +24 -24
  88. package/dist/ui/use-app-input.js +42 -8
  89. package/dist/ui/use-command-executor.js +21 -14
  90. package/dist/ui/visualizers/gallop.js +1 -1
  91. package/dist/ui/visualizers/receiver-style-registry.js +2 -1
  92. package/dist/update-check.js +2 -1
  93. package/docs/THIRD_PARTY_NOTICES.md +6 -4
  94. package/package.json +34 -11
  95. package/scripts/build-airplay-native.mjs +22 -0
  96. package/vendor/node-airtunes2/LICENSE +208 -0
  97. package/vendor/node-airtunes2/RADIOCLI_PATCH.md +29 -0
  98. package/vendor/node-airtunes2/README.md +225 -0
  99. package/vendor/node-airtunes2/UPSTREAM.json +50 -0
  100. package/vendor/node-airtunes2/binding.gyp +41 -0
  101. package/vendor/node-airtunes2/lib/ap2_test.js +4 -0
  102. package/vendor/node-airtunes2/lib/atv.js +307 -0
  103. package/vendor/node-airtunes2/lib/atvAuthenticator.js +136 -0
  104. package/vendor/node-airtunes2/lib/audio_out.js +69 -0
  105. package/vendor/node-airtunes2/lib/circular_buffer.js +133 -0
  106. package/vendor/node-airtunes2/lib/config.js +26 -0
  107. package/vendor/node-airtunes2/lib/device_airtunes.js +532 -0
  108. package/vendor/node-airtunes2/lib/devices.js +266 -0
  109. package/vendor/node-airtunes2/lib/ed25519.js +26 -0
  110. package/vendor/node-airtunes2/lib/homekit/credentials.js +86 -0
  111. package/vendor/node-airtunes2/lib/homekit/encryption.js +69 -0
  112. package/vendor/node-airtunes2/lib/homekit/number.js +44 -0
  113. package/vendor/node-airtunes2/lib/homekit/tlv.js +95 -0
  114. package/vendor/node-airtunes2/lib/http.js +189 -0
  115. package/vendor/node-airtunes2/lib/index.d.ts +141 -0
  116. package/vendor/node-airtunes2/lib/index.js +127 -0
  117. package/vendor/node-airtunes2/lib/ntp.js +37 -0
  118. package/vendor/node-airtunes2/lib/num_util.js +21 -0
  119. package/vendor/node-airtunes2/lib/packet_pool.js +43 -0
  120. package/vendor/node-airtunes2/lib/rtsp.js +1807 -0
  121. package/vendor/node-airtunes2/lib/srp.js +158 -0
  122. package/vendor/node-airtunes2/lib/udp_servers.js +214 -0
  123. package/vendor/node-airtunes2/lib/util.js +12 -0
  124. package/vendor/node-airtunes2/package.json +31 -0
  125. package/vendor/node-airtunes2/src/CADebugMacros.h +257 -0
  126. package/vendor/node-airtunes2/src/CAHostTimeBase.cpp +105 -0
  127. package/vendor/node-airtunes2/src/CAHostTimeBase.h +224 -0
  128. package/vendor/node-airtunes2/src/aes_utils.c +864 -0
  129. package/vendor/node-airtunes2/src/aes_utils.h +24 -0
  130. package/vendor/node-airtunes2/src/base64.c +136 -0
  131. package/vendor/node-airtunes2/src/base64.h +42 -0
  132. package/vendor/node-airtunes2/src/bindings.cc +29 -0
  133. package/vendor/node-airtunes2/src/codec.cc +170 -0
  134. package/vendor/node-airtunes2/src/coreaudio.cc +332 -0
@@ -0,0 +1,307 @@
1
+ const crypto = require('crypto')
2
+ const fs = require('fs')
3
+ const bplistCreator = require('bplist-creator')
4
+ const bplistParser = require('bplist-parser')
5
+ const SRP = require('./srp')
6
+ const ATVAuthenticator = require('./atvAuthenticator')
7
+ const httpClientFactory = require('./http')
8
+
9
+ // ...
10
+ // Configuration.
11
+ const loadConfig = configFilePath => !fs.existsSync(configFilePath) ? null : JSON.parse(fs.readFileSync(configFilePath))
12
+ const saveConfig = (configFilePath, config) => fs.writeFileSync(configFilePath, JSON.stringify(config, null, '\t'))
13
+
14
+ // ...
15
+
16
+ class ATV {
17
+ constructor(addr, port) {
18
+ this.addr = addr
19
+ this.port = port || 7000
20
+
21
+ this.httpClient = httpClientFactory()
22
+ this.auth_secret = null
23
+ }
24
+
25
+ // ...
26
+
27
+ auth(configFilePath, authenticator) {
28
+ async function auth(owner) {
29
+ await owner.httpClient.connect(owner.addr, owner.port)
30
+
31
+ let conf = loadConfig(configFilePath)
32
+ if (!conf || !conf['auth_secret']) {
33
+ // a pairing does not exist and must be performed.
34
+
35
+ // ...
36
+ // SRP parameters.
37
+ const srp = new SRP(2048)
38
+
39
+ let I = '366B4165DD64AD3A'
40
+ let P
41
+ let s
42
+ let B
43
+ let a
44
+ let A
45
+ let M1
46
+
47
+ await owner.httpClient.request('POST', '/pair-pin-start')
48
+ .then(() => authenticator())
49
+ .then((pin) => {
50
+ P = pin
51
+
52
+ return owner.httpClient.request(
53
+ 'POST',
54
+ '/pair-setup-pin',
55
+ {
56
+ 'Content-Type': 'application/x-apple-binary-plist',
57
+ },
58
+ bplistCreator({
59
+ user: '366B4165DD64AD3A',
60
+ method: 'pin',
61
+ }),
62
+ )
63
+ })
64
+ .then((res) => {
65
+ const { pk, salt } = bplistParser.parseBuffer(res.body)[0]
66
+
67
+ s = salt.toString('hex')
68
+ B = pk.toString('hex')
69
+
70
+ // SRP: Generate random auth_secret, 'a'; if pairing is successful, it'll be utilized in
71
+ // subsequent session authentication(s).
72
+ a = crypto.randomBytes(32).toString('hex')
73
+
74
+ // SRP: Compute A and M1.
75
+ A = srp.A(a)
76
+ M1 = srp.M1(I, P, s, a, B)
77
+
78
+ return owner.httpClient.request(
79
+ 'POST',
80
+ '/pair-setup-pin',
81
+ {
82
+ 'Content-Type': 'application/x-apple-binary-plist',
83
+ },
84
+ bplistCreator({
85
+ pk: Buffer.from(A, 'hex'),
86
+ proof: Buffer.from(M1, 'hex'),
87
+ }),
88
+ )
89
+ })
90
+ .then(() => {
91
+ // confirm the auth secret (a).
92
+ const { epk, authTag } = ATVAuthenticator.confirm(a, srp.K(I, P, s, a, B))
93
+
94
+ // complete pair-setup-pin by registering the auth secret with the target device.
95
+ return owner.httpClient.request(
96
+ 'POST',
97
+ '/pair-setup-pin',
98
+ {
99
+ 'Content-Type': 'application/x-apple-binary-plist',
100
+ },
101
+ bplistCreator({
102
+ epk: Buffer.from(epk, 'hex'),
103
+ authTag: Buffer.from(authTag, 'hex'),
104
+ }),
105
+ )
106
+ })
107
+ .then(() => {
108
+ // save the auth secret for subsequent session authentication(s).
109
+ !conf && (conf = {})
110
+ conf['auth_secret'] = a
111
+ saveConfig(configFilePath, conf)
112
+ })
113
+ }
114
+
115
+ // ...
116
+ // Authenticate session with the target device using existing pairing information.
117
+ const verifier = ATVAuthenticator.verifier(conf['auth_secret'])
118
+
119
+ return owner.httpClient.request(
120
+ 'POST',
121
+ '/pair-verify',
122
+ {
123
+ 'Content-Type': 'application/octet-stream',
124
+ },
125
+ verifier.verifierBody,
126
+ )
127
+ .then((res) => {
128
+ const atv_pub = res.body.slice(0, 32).toString('hex')
129
+ const atv_data = res.body.slice(32).toString('hex')
130
+
131
+ const shared = ATVAuthenticator.shared(verifier.v_pri, atv_pub)
132
+ const signed = ATVAuthenticator.signed(conf['auth_secret'], verifier.v_pub, atv_pub)
133
+ const signature = Buffer.from(
134
+ Buffer.from([0x00, 0x00, 0x00, 0x00]).toString('hex')
135
+ + ATVAuthenticator.signature(shared, atv_data, signed),
136
+ 'hex',
137
+ )
138
+
139
+ return owner.httpClient.request(
140
+ 'POST',
141
+ '/pair-verify',
142
+ {
143
+ 'Content-Type': 'application/octet-stream',
144
+ },
145
+ signature,
146
+ )
147
+ })
148
+ };
149
+
150
+ return auth(this)
151
+ }
152
+
153
+ authSecret() {
154
+ return this.auth_secret
155
+ }
156
+
157
+ authSimple(authenticator) {
158
+ async function auth(owner) {
159
+ await owner.httpClient.connect(owner.addr, owner.port)
160
+
161
+ let conf = null
162
+ if (!conf || !conf['auth_secret']) {
163
+ // a pairing does not exist and must be performed.
164
+
165
+ // ...
166
+ // SRP parameters.
167
+ const srp = new SRP(2048)
168
+
169
+ let I = '366B4165DD64AD3A'
170
+ let P
171
+ let s
172
+ let B
173
+ let a
174
+ let A
175
+ let M1
176
+
177
+ await owner.httpClient.request('POST', '/pair-pin-start')
178
+ .then(() => authenticator())
179
+ .then((pin) => {
180
+ P = pin
181
+
182
+ return owner.httpClient.request(
183
+ 'POST',
184
+ '/pair-setup-pin',
185
+ {
186
+ 'Content-Type': 'application/x-apple-binary-plist',
187
+ },
188
+ bplistCreator({
189
+ user: '366B4165DD64AD3A',
190
+ method: 'pin',
191
+ }),
192
+ )
193
+ })
194
+ .then((res) => {
195
+ const { pk, salt } = bplistParser.parseBuffer(res.body)[0]
196
+
197
+ s = salt.toString('hex')
198
+ B = pk.toString('hex')
199
+
200
+ // SRP: Generate random auth_secret, 'a'; if pairing is successful, it'll be utilized in
201
+ // subsequent session authentication(s).
202
+ a = crypto.randomBytes(32).toString('hex')
203
+
204
+ // SRP: Compute A and M1.
205
+ A = srp.A(a)
206
+ M1 = srp.M1(I, P, s, a, B)
207
+
208
+ return owner.httpClient.request(
209
+ 'POST',
210
+ '/pair-setup-pin',
211
+ {
212
+ 'Content-Type': 'application/x-apple-binary-plist',
213
+ },
214
+ bplistCreator({
215
+ pk: Buffer.from(A, 'hex'),
216
+ proof: Buffer.from(M1, 'hex'),
217
+ }),
218
+ )
219
+ }).then(() => {
220
+ // confirm the auth secret (a).
221
+ const { epk, authTag } = ATVAuthenticator.confirm(a, srp.K(I, P, s, a, B))
222
+
223
+ // complete pair-setup-pin by registering the auth secret with the target device.
224
+ return owner.httpClient.request(
225
+ 'POST',
226
+ '/pair-setup-pin',
227
+ {
228
+ 'Content-Type': 'application/x-apple-binary-plist',
229
+ },
230
+ bplistCreator({
231
+ epk: Buffer.from(epk, 'hex'),
232
+ authTag: Buffer.from(authTag, 'hex'),
233
+ }),
234
+ )
235
+ })
236
+ .then(() => {
237
+ // save the auth secret for subsequent session authentication(s).
238
+ console.log(a)
239
+ owner.auth_secret = a
240
+ })
241
+ }
242
+ }
243
+ return auth(this)
244
+ }
245
+
246
+ verifySimple(secret) {
247
+ // ...
248
+ // Authenticate session with the target device using existing pairing information.
249
+ const verifier = ATVAuthenticator.verifier(secret)
250
+
251
+ return owner.httpClient.request(
252
+ 'POST',
253
+ '/pair-verify',
254
+ {
255
+ 'Content-Type': 'application/octet-stream',
256
+ },
257
+ verifier.verifierBody,
258
+ )
259
+ .then((res) => {
260
+ const atv_pub = res.body.slice(0, 32).toString('hex')
261
+ const atv_data = res.body.slice(32).toString('hex')
262
+
263
+ const shared = ATVAuthenticator.shared(verifier.v_pri, atv_pub)
264
+ const signed = ATVAuthenticator.signed(secret, verifier.v_pub, atv_pub)
265
+ const signature = Buffer.from(
266
+ Buffer.from([0x00, 0x00, 0x00, 0x00]).toString('hex')
267
+ + ATVAuthenticator.signature(shared, atv_data, signed),
268
+ 'hex',
269
+ )
270
+
271
+ return owner.httpClient.request(
272
+ 'POST',
273
+ '/pair-verify',
274
+ {
275
+ 'Content-Type': 'application/octet-stream',
276
+ },
277
+ signature,
278
+ )
279
+ })
280
+ }
281
+
282
+ play(videoUrl) {
283
+ return this.httpClient.request(
284
+ 'POST',
285
+ '/play',
286
+ {
287
+ 'Content-Type': 'application/x-apple-binary-plist',
288
+ },
289
+ bplistCreator({
290
+ 'Content-Location': videoUrl,
291
+ 'Start-Location': 0,
292
+ }),
293
+ )
294
+ }
295
+
296
+ stop() {
297
+ return this.httpClient.request('POST', '/stop')
298
+ }
299
+
300
+ close() {
301
+ this.httpClient.close()
302
+ }
303
+ }
304
+
305
+ // ...
306
+
307
+ module.exports = ATV
@@ -0,0 +1,136 @@
1
+ // RadioCLI modification, 2026-10-16: replace elliptic with native Ed25519.
2
+ // Distributed under the accompanying AGPLv3; pairing formats remain unchanged.
3
+ const ed25519 = require('./ed25519')
4
+ const crypto = require('crypto')
5
+ const axlsign = require('axlsign')
6
+ const { hexString2ArrayBuffer, buf2hex } = require('./util')
7
+
8
+ // ...
9
+ // Note: All functions expect parameters to be hex strings.
10
+
11
+ function pair_setup_aes_key(K) {
12
+ return crypto.createHash('sha512')
13
+ .update('Pair-Setup-AES-Key')
14
+ .update(hexString2ArrayBuffer(K))
15
+ .digest('hex')
16
+ .substring(0, 32)
17
+ }
18
+
19
+ function pair_setup_aes_iv(K) {
20
+ let ab = crypto.createHash('sha512')
21
+ .update('Pair-Setup-AES-IV')
22
+ .update(hexString2ArrayBuffer(K))
23
+ .digest()
24
+
25
+ ab = ab.slice(0, 16)
26
+ ab[ab.length - 1] += 0x01
27
+
28
+ return buf2hex(ab)
29
+ }
30
+
31
+ function pair_verify_aes_key(shared) {
32
+ return buf2hex(
33
+ crypto.createHash('sha512')
34
+ .update('Pair-Verify-AES-Key')
35
+ .update(hexString2ArrayBuffer(shared))
36
+ .digest()
37
+ .slice(0, 16),
38
+ )
39
+ }
40
+
41
+ function pair_verify_aes_iv(shared) {
42
+ return buf2hex(
43
+ crypto.createHash('sha512')
44
+ .update('Pair-Verify-AES-IV')
45
+ .update(hexString2ArrayBuffer(shared))
46
+ .digest()
47
+ .slice(0, 16),
48
+ )
49
+ }
50
+
51
+ // ...
52
+ // Public.
53
+
54
+ function a_pub(a) {
55
+ return ed25519.publicKey(a)
56
+ }
57
+
58
+ function confirm(a, K) {
59
+ const key = pair_setup_aes_key(K)
60
+ const iv = pair_setup_aes_iv(K)
61
+
62
+ const cipher = crypto.createCipheriv(
63
+ 'aes-128-gcm',
64
+ hexString2ArrayBuffer(key),
65
+ hexString2ArrayBuffer(iv),
66
+ )
67
+
68
+ let encrypted = cipher.update(hexString2ArrayBuffer(a_pub(a)), null, 'hex')
69
+ encrypted += cipher.final('hex')
70
+
71
+ return {
72
+ epk: encrypted,
73
+ authTag: buf2hex(cipher.getAuthTag()),
74
+ }
75
+ }
76
+
77
+ function verifier(a) {
78
+ const keyPair = axlsign.generateKeyPair(crypto.randomBytes(32))
79
+ const v_pri = buf2hex(keyPair.private)
80
+ const v_pub = buf2hex(keyPair.public)
81
+
82
+ const header = Buffer.from([0x01, 0x00, 0x00, 0x00])
83
+ const a_pub_buf = Buffer.from(a_pub(a), 'hex')
84
+
85
+ return {
86
+ verifierBody: Buffer.concat(
87
+ [header, keyPair.public, a_pub_buf],
88
+ header.byteLength + keyPair.public.byteLength + a_pub_buf.byteLength,
89
+ ),
90
+ v_pri,
91
+ v_pub,
92
+ }
93
+ }
94
+
95
+ function shared(v_pri, atv_pub) {
96
+ return buf2hex(
97
+ axlsign.sharedKey(
98
+ hexString2ArrayBuffer(v_pri),
99
+ hexString2ArrayBuffer(atv_pub),
100
+ ),
101
+ )
102
+ }
103
+
104
+ function signed(a, v_pub, atv_pub) {
105
+ return ed25519.sign(a, v_pub + atv_pub)
106
+ }
107
+
108
+ function signature(shared, atv_data, signed) {
109
+ const cipher = crypto.createCipheriv(
110
+ 'aes-128-ctr',
111
+ hexString2ArrayBuffer(pair_verify_aes_key(shared)),
112
+ hexString2ArrayBuffer(pair_verify_aes_iv(shared)),
113
+ )
114
+
115
+ // discard the result of encrypting atv_data.
116
+ cipher.update(hexString2ArrayBuffer(atv_data))
117
+
118
+ let encrypted = cipher.update(Buffer.from(signed, 'hex'), null, 'hex')
119
+ encrypted += cipher.final('hex')
120
+
121
+ return encrypted
122
+ }
123
+
124
+ module.exports = {
125
+ pair_setup_aes_key,
126
+ pair_setup_aes_iv,
127
+ pair_verify_aes_key,
128
+ pair_verify_aes_iv,
129
+
130
+ a_pub,
131
+ confirm,
132
+ verifier,
133
+ shared,
134
+ signed,
135
+ signature,
136
+ }
@@ -0,0 +1,69 @@
1
+ const events = require('events')
2
+ const util = require('util')
3
+ const config = require('./config')
4
+ const nu = require('./num_util')
5
+
6
+ const SEQ_NUM_WRAP = Math.pow(2, 16)
7
+
8
+ function AudioOut() {
9
+ events.EventEmitter.call(this)
10
+
11
+ this.lastSeq = -1
12
+ this.hasAirTunes = false
13
+ }
14
+
15
+ util.inherits(AudioOut, events.EventEmitter)
16
+
17
+ AudioOut.prototype.init = function (devices, circularBuffer) {
18
+ var self = this
19
+ config.rtp_time_ref = new Date().getTime()
20
+
21
+ devices.on('airtunes_devices', function (hasAirTunes) {
22
+ self.hasAirTunes = hasAirTunes
23
+ })
24
+
25
+ devices.on('need_sync', function () {
26
+ // a sync is forced when a new remote device is added
27
+ self.emit('need_sync', self.lastSeq)
28
+ })
29
+
30
+ function sendPacket(seq) {
31
+ var packet = circularBuffer.readPacket()
32
+
33
+ packet.seq = seq % SEQ_NUM_WRAP
34
+ packet.timestamp = nu.low32(seq * config.frames_per_packet + 2 * config.sampling_rate)
35
+
36
+ if (self.hasAirTunes && seq % config.sync_period == 0)
37
+ self.emit('need_sync', seq)
38
+
39
+ self.emit('packet', packet)
40
+ packet.release()
41
+ }
42
+
43
+ function syncAudio() {
44
+ /*
45
+ * Each time syncAudio() runs, a burst of packet is sent.
46
+ * Increasing config.stream_latency lowers CPU usage but increases the size of the burst.
47
+ * If the burst size exceeds the UDP windows size (which we do not know), packets are lost.
48
+ */
49
+ var elapsed = new Date().getTime() - config.rtp_time_ref
50
+
51
+ /*
52
+ * currentSeq is the # of the packet we should be sending now. We have some packets to catch-up
53
+ * since syncAudio is not always running.
54
+ */
55
+ var currentSeq = Math.floor(elapsed * config.sampling_rate / (config.frames_per_packet * 1000))
56
+
57
+ for (let i = self.lastSeq + 1; i <= currentSeq; i++)
58
+ sendPacket(i)
59
+
60
+ self.lastSeq = currentSeq
61
+
62
+ // reschedule ourselves later
63
+ setTimeout(syncAudio, config.stream_latency)
64
+ }
65
+
66
+ syncAudio()
67
+ }
68
+
69
+ module.exports = AudioOut
@@ -0,0 +1,133 @@
1
+ const Stream = require('stream')
2
+ const util = require('util')
3
+ const PacketPool = require('./packet_pool')
4
+
5
+ const WAITING = 0
6
+ const FILLING = 1
7
+ const NORMAL = 2
8
+ const DRAINING = 3
9
+ const ENDING = 4
10
+ const ENDED = 5
11
+
12
+ function CircularBuffer(packetsInBuffer, packetSize) {
13
+ Stream.call(this)
14
+
15
+ this.packetPool = new PacketPool()
16
+ this.maxSize = packetsInBuffer * packetSize
17
+ this.packetSize = packetSize
18
+ this.writable = true
19
+ this.muted = false
20
+ this.buffers = []
21
+ this.currentSize = 0
22
+ this.status = WAITING
23
+ }
24
+
25
+ util.inherits(CircularBuffer, Stream)
26
+
27
+ CircularBuffer.prototype.write = function (chunk) {
28
+ this.buffers.push(chunk)
29
+ this.currentSize += chunk.length
30
+
31
+ if (this.status === ENDING || this.status === ENDED)
32
+ throw new Error('Cannot write in buffer after closing it')
33
+
34
+ if (this.status === WAITING) {
35
+ // notify when we receive the first chunk
36
+ this.emit('status', 'buffering')
37
+ this.status = FILLING
38
+ }
39
+
40
+ if (this.status === FILLING && this.currentSize > this.maxSize / 2) {
41
+ this.status = NORMAL
42
+ this.emit('status', 'playing')
43
+ }
44
+
45
+ if (this.currentSize >= this.maxSize) {
46
+ this.status = DRAINING
47
+ return false
48
+ }
49
+ else {
50
+ return true
51
+ }
52
+ }
53
+
54
+ CircularBuffer.prototype.readPacket = function () {
55
+ var packet = this.packetPool.getPacket()
56
+
57
+ // play silence until buffer is filled enough
58
+ if (this.status !== ENDING && this.status !== ENDED
59
+ && (this.status === FILLING || this.currentSize < this.packetSize)) {
60
+ packet.pcm.fill(0)
61
+
62
+ if (this.status !== FILLING && this.status !== WAITING) {
63
+ this.status = FILLING
64
+ this.emit('status', 'buffering')
65
+ }
66
+ }
67
+ else {
68
+ var offset = 0, remaining = this.packetSize
69
+
70
+ // fill a whole packet with data
71
+ while (remaining > 0) {
72
+ // pad packet with silence if buffer is empty
73
+ if (this.buffers.length === 0) {
74
+ packet.pcm.fill(0, offset)
75
+ remaining = 0
76
+ break
77
+ }
78
+
79
+ var first = this.buffers[0]
80
+
81
+ if (first.length <= remaining) {
82
+ // pop the whole buffer from the queue
83
+ first.copy(packet.pcm, offset)
84
+ offset += first.length
85
+ remaining -= first.length
86
+ this.buffers.shift()
87
+ }
88
+ else {
89
+ // first buffer contains enough data to fill a packet: slice it
90
+ first.copy(packet.pcm, offset, 0, remaining)
91
+ this.buffers[0] = first.slice(remaining)
92
+ offset += remaining
93
+ remaining = 0
94
+ }
95
+ }
96
+
97
+ this.currentSize -= this.packetSize
98
+
99
+ // emit 'end' only once
100
+ if (this.status === ENDING && this.currentSize <= 0) {
101
+ this.status = ENDED
102
+ this.currentSize = 0
103
+ this.emit('status', 'end')
104
+ }
105
+
106
+ // notify that the buffer now has enough room if needed
107
+ if (this.status === DRAINING && this.currentSize < this.maxSize / 2) {
108
+ this.status = NORMAL
109
+ this.emit('drain')
110
+ }
111
+ }
112
+
113
+ if (this.muted)
114
+ packet.pcm.fill(0)
115
+
116
+ return packet
117
+ }
118
+
119
+ CircularBuffer.prototype.end = function () {
120
+ // flush the buffer if it was filling
121
+ if (this.status === FILLING)
122
+ this.emit('status', 'playing')
123
+
124
+ this.status = ENDING
125
+ }
126
+
127
+ CircularBuffer.prototype.reset = function () {
128
+ this.buffers = []
129
+ this.currentSize = 0
130
+ this.status = WAITING
131
+ }
132
+
133
+ module.exports = CircularBuffer
@@ -0,0 +1,26 @@
1
+ const nu = require('./num_util')
2
+
3
+ const Config = {
4
+ user_agent: 'iTunes/11.3.1 (Windows; Microsoft Windows 10 x64 (Build 19044); x64) (dt:2)',
5
+ udp_default_port: 54621, // preferred starting port in AirTunes v2
6
+ frames_per_packet: 352, // samples per frames in ALAC packets
7
+ channels_per_frame: 2, // always stereo in AirTunes v2
8
+ bits_per_channel: 16, // -> 2 bytes per channel
9
+ packet_size: 352 * 2 * 2, // frames*channels*bytes
10
+ packets_in_buffer: 200, // increase this buffer protects against network issues
11
+ coreaudio_min_level: 5, // if CoreAudio's internal buffer drops too much, inject some silence to raise it
12
+ coreaudio_check_period: 2000, // CoreAudio buffer level check period
13
+ coreaudio_preload: 352 * 2 * 2 * 50, // ~0.5s of silence pushed to CoreAudio to avoid draining AudioQueue
14
+ sampling_rate: 44100, // fixed by AirTunes v2
15
+ sync_period: 126, // UDP sync packets are sent to all AirTunes devices regularly
16
+ stream_latency: 100, // audio UDP packets are flushed in bursts periodically
17
+ rtsp_timeout: 2147483647, // RTSP servers are considered gone if no reply is received before the timeout
18
+ rtsp_heartbeat: 15000, // some RTSP (like HomePod) servers requires heartbeat. Recommanded value: 60000 (1 minute). Set to 0 if you want to disable it.
19
+ rtp_time_ref: 0,
20
+ device_magic: nu.randomInt(9),
21
+ ntp_epoch: 0x83aa7e80,
22
+ iv_base64: 'ePRBLI0XN5ArFaaz7ncNZw',
23
+ rsa_aeskey_base64: 'VjVbxWcmYgbBbhwBNlCh3K0CMNtWoB844BuiHGUJT51zQS7SDpMnlbBIobsKbfEJ3SCgWHRXjYWf7VQWRYtEcfx7ejA8xDIk5PSBYTvXP5dU2QoGrSBv0leDS6uxlEWuxBq3lIxCxpWO2YswHYKJBt06Uz9P2Fq2hDUwl3qOQ8oXb0OateTKtfXEwHJMprkhsJsGDrIc5W5NJFMAo6zCiM9bGSDeH2nvTlyW6bfI/Q0v0cDGUNeY3ut6fsoafRkfpCwYId+bg3diJh+uzw5htHDyZ2sN+BFYHzEfo8iv4KDxzeya9llqg6fRNQ8d5YjpvTnoeEQ9ye9ivjkBjcAfVw',
24
+ }
25
+
26
+ module.exports = Config