buzz-me-in 2.2.0 → 2.2.2-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/homekit.js +16 -119
- package/package.json +4 -4
- package/server.js +84 -32
- package/src/core/inbound-ffmpeg-args.js +163 -0
- package/src/core/media-stream.js +8 -7
- package/src/core/mulaw-audio.js +3 -3
- package/src/core/state.js +1 -1
- package/src/core/types.js +1 -1
package/README.md
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+

|
|
2
|
+
|
|
1
3
|
# buzz-me-in
|
|
2
4
|
|
|
3
5
|
[](https://www.npmjs.com/package/buzz-me-in)
|
package/homekit.js
CHANGED
|
@@ -26,6 +26,7 @@ const { spawn } = require('child_process');
|
|
|
26
26
|
const config = require('./src/core/config');
|
|
27
27
|
const state = require('./src/core/state');
|
|
28
28
|
const { sendDtmfSequence, sendMulawAudio } = require('./src/core/mulaw-audio');
|
|
29
|
+
const { buildInboundFfmpegArgs } = require('./src/core/inbound-ffmpeg-args');
|
|
29
30
|
const {
|
|
30
31
|
removeReturnAudioSdp,
|
|
31
32
|
removeSdpDir,
|
|
@@ -281,127 +282,23 @@ function _startSession(sessionID, s, request, callback) {
|
|
|
281
282
|
// Reopening the live view inside the grace window means the user is not
|
|
282
283
|
// finished after all.
|
|
283
284
|
cancelPendingHangUp('homekit-session-restarted');
|
|
284
|
-
const videoParams = srtpParams(s.hkVideoKey, s.hkVideoSalt);
|
|
285
|
-
const audioParams = srtpParams(s.hkAudioKey, s.hkAudioSalt);
|
|
286
|
-
const video = request.video;
|
|
287
285
|
const audio = request.audio;
|
|
288
|
-
const videoBitrate = Math.max(64, video.max_bit_rate || 200);
|
|
289
|
-
const videoBufferSize = Math.max(videoBitrate * 2, 128);
|
|
290
|
-
const mtu = video.mtu || 1316;
|
|
291
286
|
|
|
292
|
-
//
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
// requests it when the live view is first opened; without frequent IDRs
|
|
308
|
-
// the video stays blank until the next natural keyframe.
|
|
309
|
-
//
|
|
310
|
-
// Two separate SRTP outputs — no muxing, no pts coupling between streams.
|
|
311
|
-
// -------------------------------------------------------------------------
|
|
312
|
-
const ffIn = spawn('ffmpeg', [
|
|
313
|
-
'-y',
|
|
314
|
-
'-loglevel',
|
|
315
|
-
'warning',
|
|
316
|
-
|
|
317
|
-
// ---- Input 0: raw mulaw from Twilio ----
|
|
318
|
-
'-thread_queue_size',
|
|
319
|
-
'512',
|
|
320
|
-
'-f',
|
|
321
|
-
'mulaw',
|
|
322
|
-
'-ar',
|
|
323
|
-
'8000',
|
|
324
|
-
'-ch_layout',
|
|
325
|
-
'mono',
|
|
326
|
-
'-i',
|
|
327
|
-
'pipe:0',
|
|
328
|
-
|
|
329
|
-
// ---- Input 1: blank video ----
|
|
330
|
-
'-f',
|
|
331
|
-
'lavfi',
|
|
332
|
-
'-i',
|
|
333
|
-
'color=black:s=1280x720:r=15',
|
|
334
|
-
|
|
335
|
-
// ---- Video output → HomeKit SRTP ----
|
|
336
|
-
'-map',
|
|
337
|
-
'1:v',
|
|
338
|
-
'-c:v',
|
|
339
|
-
'libx264',
|
|
340
|
-
'-profile:v',
|
|
341
|
-
'baseline',
|
|
342
|
-
'-level:v',
|
|
343
|
-
'3.1',
|
|
344
|
-
'-preset',
|
|
345
|
-
'ultrafast',
|
|
346
|
-
'-tune',
|
|
347
|
-
'zerolatency',
|
|
348
|
-
'-pix_fmt',
|
|
349
|
-
'yuv420p',
|
|
350
|
-
'-b:v',
|
|
351
|
-
`${videoBitrate}k`,
|
|
352
|
-
'-maxrate',
|
|
353
|
-
`${videoBitrate}k`,
|
|
354
|
-
'-bufsize',
|
|
355
|
-
`${videoBufferSize}k`,
|
|
356
|
-
'-g',
|
|
357
|
-
String(video.fps || 15),
|
|
358
|
-
'-keyint_min',
|
|
359
|
-
String(video.fps || 15),
|
|
360
|
-
'-payload_type',
|
|
361
|
-
String(video.pt),
|
|
362
|
-
'-ssrc',
|
|
363
|
-
String(s.videoSsrc),
|
|
364
|
-
'-f',
|
|
365
|
-
'rtp',
|
|
366
|
-
'-srtp_out_suite',
|
|
367
|
-
'AES_CM_128_HMAC_SHA1_80',
|
|
368
|
-
'-srtp_out_params',
|
|
369
|
-
videoParams,
|
|
370
|
-
`srtp://${s.targetAddress}:${s.hkVideoPort}?rtcpport=${s.hkVideoPort}&localrtcpport=${s.hkVideoPort}&pkt_size=${mtu}`,
|
|
371
|
-
|
|
372
|
-
// ---- Audio output → HomeKit SRTP (Opus/16kHz) ----
|
|
373
|
-
//
|
|
374
|
-
// Note on codec choice: libopus is in every standard ffmpeg build.
|
|
375
|
-
// If you prefer AAC-ELD (required by some older HomeKit devices), compile
|
|
376
|
-
// ffmpeg with --enable-libfdk-aac --enable-nonfree and change:
|
|
377
|
-
// '-c:a', 'libfdk_aac', '-profile:a', 'aac_eld',
|
|
378
|
-
// and update streamingOptions.audio.codecs below to AAC_ELD.
|
|
379
|
-
'-map',
|
|
380
|
-
'0:a',
|
|
381
|
-
'-c:a',
|
|
382
|
-
'libopus',
|
|
383
|
-
'-ar',
|
|
384
|
-
'16000',
|
|
385
|
-
'-ac',
|
|
386
|
-
'1',
|
|
387
|
-
'-b:a',
|
|
388
|
-
'24k',
|
|
389
|
-
'-application',
|
|
390
|
-
'voip',
|
|
391
|
-
'-frame_duration',
|
|
392
|
-
'20',
|
|
393
|
-
'-payload_type',
|
|
394
|
-
String(audio.pt),
|
|
395
|
-
'-ssrc',
|
|
396
|
-
String(s.audioSsrc),
|
|
397
|
-
'-f',
|
|
398
|
-
'rtp',
|
|
399
|
-
'-srtp_out_suite',
|
|
400
|
-
'AES_CM_128_HMAC_SHA1_80',
|
|
401
|
-
'-srtp_out_params',
|
|
402
|
-
audioParams,
|
|
403
|
-
`srtp://${s.targetAddress}:${s.hkAudioPort}?rtcpport=${s.hkAudioPort}&localrtcpport=${s.hkAudioPort}`,
|
|
404
|
-
]);
|
|
287
|
+
// Inbound ffmpeg: see src/core/inbound-ffmpeg-args.js for the pipeline.
|
|
288
|
+
const ffIn = spawn(
|
|
289
|
+
'ffmpeg',
|
|
290
|
+
buildInboundFfmpegArgs({
|
|
291
|
+
video: request.video,
|
|
292
|
+
audio,
|
|
293
|
+
videoParams: srtpParams(s.hkVideoKey, s.hkVideoSalt),
|
|
294
|
+
audioParams: srtpParams(s.hkAudioKey, s.hkAudioSalt),
|
|
295
|
+
targetAddress: s.targetAddress,
|
|
296
|
+
hkVideoPort: s.hkVideoPort,
|
|
297
|
+
hkAudioPort: s.hkAudioPort,
|
|
298
|
+
videoSsrc: s.videoSsrc,
|
|
299
|
+
audioSsrc: s.audioSsrc,
|
|
300
|
+
})
|
|
301
|
+
);
|
|
405
302
|
|
|
406
303
|
ffIn.stderr.on('data', (d) => {
|
|
407
304
|
logger.warn('Inbound ffmpeg stderr', {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "buzz-me-in",
|
|
3
|
-
"version": "2.2.0",
|
|
3
|
+
"version": "2.2.2-beta.0",
|
|
4
4
|
"description": "HomeKit-compatible apartment intercom via Twilio media streams",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"homekit",
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
],
|
|
16
16
|
"main": "server.js",
|
|
17
17
|
"bin": {
|
|
18
|
-
"buzz-me-in": "
|
|
18
|
+
"buzz-me-in": "server.js"
|
|
19
19
|
},
|
|
20
20
|
"files": [
|
|
21
21
|
"server.js",
|
|
@@ -57,14 +57,14 @@
|
|
|
57
57
|
"hap-nodejs": "^0.14.2",
|
|
58
58
|
"qrcode-terminal": "^0.12.0",
|
|
59
59
|
"twilio": "^6.1.0",
|
|
60
|
-
"
|
|
60
|
+
"ws": "^8.22.0",
|
|
61
61
|
"zod": "^4.5.4"
|
|
62
62
|
},
|
|
63
63
|
"devDependencies": {
|
|
64
64
|
"@commitlint/cli": "^21.2.2",
|
|
65
65
|
"@commitlint/config-conventional": "^21.0.2",
|
|
66
66
|
"@eslint/js": "^10.0.1",
|
|
67
|
-
"@types/
|
|
67
|
+
"@types/ws": "^8.18.1",
|
|
68
68
|
"eslint": "^10.9.1",
|
|
69
69
|
"eslint-config-prettier": "^10.1.8",
|
|
70
70
|
"globals": "^17.11.0",
|
package/server.js
CHANGED
|
@@ -29,7 +29,7 @@ if (require.main === module) {
|
|
|
29
29
|
|
|
30
30
|
const http = require('http');
|
|
31
31
|
const twilio = require('twilio');
|
|
32
|
-
const WebSocketServer = require('
|
|
32
|
+
const { WebSocketServer } = require('ws');
|
|
33
33
|
|
|
34
34
|
const config = require('./src/core/config');
|
|
35
35
|
const state = require('./src/core/state');
|
|
@@ -45,7 +45,7 @@ const homekit = require('./homekit');
|
|
|
45
45
|
const { createLogger } = require('./src/core/log');
|
|
46
46
|
const { safeEqualString } = require('./src/core/safe-equal');
|
|
47
47
|
const { MediaStream } = require('./src/core/media-stream');
|
|
48
|
-
/** @import {
|
|
48
|
+
/** @import { Duplex } from 'stream' */
|
|
49
49
|
/** @import { HandshakeVerificationError } from './src/core/types' */
|
|
50
50
|
|
|
51
51
|
const HTTP_SERVER_PORT = config.port;
|
|
@@ -82,17 +82,19 @@ const RINGTONE_WAV = createRingbackWav();
|
|
|
82
82
|
|
|
83
83
|
const wsserver = http.createServer(handleRequest);
|
|
84
84
|
|
|
85
|
-
// Enforce message bounds in the
|
|
86
|
-
//
|
|
87
|
-
//
|
|
85
|
+
// Enforce message bounds in the ws library itself so oversized frames are
|
|
86
|
+
// rejected before assembly, instead of relying only on the per-message check
|
|
87
|
+
// in MediaStream (the library default allows 100 MiB).
|
|
88
88
|
const WS_LIBRARY_MAX_BYTES = MAX_WS_UTF8_BYTES * 4;
|
|
89
89
|
const MAX_CONCURRENT_WS_CONNECTIONS = config.wsMaxConnections;
|
|
90
90
|
|
|
91
|
+
// noServer: the upgrade handler below runs the handshake checks and only then
|
|
92
|
+
// hands the socket to ws. Sockets are tracked in activeWsConnections instead of
|
|
93
|
+
// by the library.
|
|
91
94
|
const mediaws = new WebSocketServer({
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
maxReceivedMessageSize: WS_LIBRARY_MAX_BYTES,
|
|
95
|
+
noServer: true,
|
|
96
|
+
clientTracking: false,
|
|
97
|
+
maxPayload: WS_LIBRARY_MAX_BYTES,
|
|
96
98
|
});
|
|
97
99
|
|
|
98
100
|
state.setOnSessionStale((session) => {
|
|
@@ -294,28 +296,64 @@ GET_ROUTES.set('/readyz', function (_req, res) {
|
|
|
294
296
|
// WebSocket media stream
|
|
295
297
|
// ---------------------------------------------------------------------------
|
|
296
298
|
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
299
|
+
/**
|
|
300
|
+
* Refuse a WebSocket upgrade with a plain HTTP response, before any WebSocket
|
|
301
|
+
* handshake is written, then drop the socket.
|
|
302
|
+
*
|
|
303
|
+
* @param {Duplex} socket
|
|
304
|
+
* @param {number} status
|
|
305
|
+
* @param {string} reason
|
|
306
|
+
* @returns {void}
|
|
307
|
+
*/
|
|
308
|
+
function rejectUpgrade(socket, status, reason) {
|
|
309
|
+
socket.end(
|
|
310
|
+
`HTTP/1.1 ${status} ${http.STATUS_CODES[status]}\r\n` +
|
|
311
|
+
'Connection: close\r\n' +
|
|
312
|
+
'Content-Type: text/plain\r\n' +
|
|
313
|
+
`Content-Length: ${Buffer.byteLength(reason)}\r\n` +
|
|
314
|
+
'\r\n' +
|
|
315
|
+
reason
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Handshake checks for the `/media` WebSocket, run before ws takes the socket.
|
|
321
|
+
* Exported so tests can mount it on their own HTTP server.
|
|
322
|
+
*
|
|
323
|
+
* @param {import('http').IncomingMessage} request
|
|
324
|
+
* @param {Duplex} socket
|
|
325
|
+
* @param {Buffer} head
|
|
326
|
+
* @returns {void}
|
|
327
|
+
*/
|
|
328
|
+
function handleMediaUpgrade(request, socket, head) {
|
|
329
|
+
// A client that resets mid-handshake would otherwise raise an unhandled
|
|
330
|
+
// 'error' and take the process down via the uncaughtException handler.
|
|
331
|
+
socket.on('error', () => socket.destroy());
|
|
300
332
|
if (shuttingDown) {
|
|
301
|
-
|
|
333
|
+
rejectUpgrade(socket, 503, 'Server shutting down');
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
// Node accepts an absolute-form target such as `http://[`, which URL cannot
|
|
337
|
+
// parse. Thrown here, it would exit the process via uncaughtException.
|
|
338
|
+
let path;
|
|
339
|
+
try {
|
|
340
|
+
path = new URL(request.url || '/', 'http://localhost').pathname;
|
|
341
|
+
} catch {
|
|
342
|
+
rejectUpgrade(socket, 400, 'Bad request');
|
|
302
343
|
return;
|
|
303
344
|
}
|
|
304
|
-
const path = wsRequest.resourceURL && wsRequest.resourceURL.pathname;
|
|
305
345
|
if (path !== STREAM_PATH) {
|
|
306
|
-
|
|
346
|
+
rejectUpgrade(socket, 404, 'Not found');
|
|
307
347
|
return;
|
|
308
348
|
}
|
|
309
349
|
// Twilio signs the Media Streams handshake, so an unauthenticated socket can
|
|
310
|
-
// be refused here rather than after
|
|
311
|
-
// the connection and allocates a MediaStream before any auth runs,
|
|
312
|
-
// the stream token only arrives later in the `start` frame. Defence
|
|
313
|
-
// the one-time token below remains the authority, since this
|
|
314
|
-
// HMAC over a constant URL and so never varies between calls.
|
|
350
|
+
// be refused here rather than after the upgrade. Without this the server
|
|
351
|
+
// accepts the connection and allocates a MediaStream before any auth runs,
|
|
352
|
+
// because the stream token only arrives later in the `start` frame. Defence
|
|
353
|
+
// in depth: the one-time token below remains the authority, since this
|
|
354
|
+
// signature is an HMAC over a constant URL and so never varies between calls.
|
|
315
355
|
if (MEDIA_SIGNATURE_MODE !== 'off') {
|
|
316
|
-
const handshake = verifyStreamHandshakeSignature(
|
|
317
|
-
wsRequest.httpRequest.headers['x-twilio-signature']
|
|
318
|
-
);
|
|
356
|
+
const handshake = verifyStreamHandshakeSignature(request.headers['x-twilio-signature']);
|
|
319
357
|
if (!handshake.ok) {
|
|
320
358
|
// strict:false disables discriminated-union narrowing; cast as elsewhere.
|
|
321
359
|
const handshakeError = /** @type {HandshakeVerificationError} */ (handshake);
|
|
@@ -325,7 +363,7 @@ mediaws.on('request', function (request) {
|
|
|
325
363
|
enforced: MEDIA_SIGNATURE_MODE === 'enforce',
|
|
326
364
|
});
|
|
327
365
|
if (MEDIA_SIGNATURE_MODE === 'enforce') {
|
|
328
|
-
|
|
366
|
+
rejectUpgrade(socket, 403, 'Forbidden');
|
|
329
367
|
return;
|
|
330
368
|
}
|
|
331
369
|
} else {
|
|
@@ -348,18 +386,31 @@ mediaws.on('request', function (request) {
|
|
|
348
386
|
event: 'media-ws-rejected',
|
|
349
387
|
reason: 'too-many-connections',
|
|
350
388
|
});
|
|
351
|
-
|
|
389
|
+
rejectUpgrade(socket, 503, 'Too many connections');
|
|
352
390
|
return;
|
|
353
391
|
}
|
|
354
392
|
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
393
|
+
mediaws.handleUpgrade(request, socket, head, function (connection) {
|
|
394
|
+
activeWsConnections.add(connection);
|
|
395
|
+
connection.on('close', () => activeWsConnections.delete(connection));
|
|
396
|
+
// ws emits 'error' for protocol violations and frames over maxPayload, then
|
|
397
|
+
// closes the socket itself; 'close' still fires, so MediaStream tears down.
|
|
398
|
+
// Unhandled, the error would exit the process.
|
|
399
|
+
connection.on('error', (err) => {
|
|
400
|
+
mediaWsLogger.warn('Media websocket protocol error', {
|
|
401
|
+
event: 'media-ws-error',
|
|
402
|
+
reason: /** @type {NodeJS.ErrnoException} */ (err).code || 'ws-error',
|
|
403
|
+
error: err,
|
|
404
|
+
});
|
|
405
|
+
});
|
|
406
|
+
mediaWsLogger.info('Media websocket connection accepted', {
|
|
407
|
+
event: 'media-ws-accepted',
|
|
408
|
+
});
|
|
409
|
+
new MediaStream(connection, mediaStreamDeps);
|
|
360
410
|
});
|
|
361
|
-
|
|
362
|
-
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
wsserver.on('upgrade', handleMediaUpgrade);
|
|
363
414
|
|
|
364
415
|
/** @type {import('./src/core/media-stream').MediaStreamDeps} */
|
|
365
416
|
const mediaStreamDeps = {
|
|
@@ -543,6 +594,7 @@ module.exports = {
|
|
|
543
594
|
beginShutdown,
|
|
544
595
|
buildTwiml,
|
|
545
596
|
describeCaller,
|
|
597
|
+
handleMediaUpgrade,
|
|
546
598
|
handleRequest,
|
|
547
599
|
isAuthorizedForStatus,
|
|
548
600
|
isValidTwilioRequest,
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Argument list for the inbound ffmpeg process: Twilio mu-law on stdin →
|
|
5
|
+
* Opus/SRTP audio plus a synthetic H.264/SRTP video track to the controller.
|
|
6
|
+
* Kept apart from homekit.js so the arguments can be tested without spawning
|
|
7
|
+
* ffmpeg or standing up a HomeKit session.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @param {{
|
|
12
|
+
* video: { max_bit_rate?: number, mtu?: number, fps?: number, pt: number },
|
|
13
|
+
* audio: { pt: number },
|
|
14
|
+
* videoParams: string,
|
|
15
|
+
* audioParams: string,
|
|
16
|
+
* targetAddress: string,
|
|
17
|
+
* hkVideoPort: number,
|
|
18
|
+
* hkAudioPort: number,
|
|
19
|
+
* videoSsrc: number,
|
|
20
|
+
* audioSsrc: number,
|
|
21
|
+
* }} input
|
|
22
|
+
* @returns {string[]}
|
|
23
|
+
*/
|
|
24
|
+
function buildInboundFfmpegArgs(input) {
|
|
25
|
+
const { video, audio, videoParams, audioParams } = input;
|
|
26
|
+
const videoBitrate = Math.max(64, video.max_bit_rate || 200);
|
|
27
|
+
const videoBufferSize = Math.max(videoBitrate * 2, 128);
|
|
28
|
+
const mtu = video.mtu || 1316;
|
|
29
|
+
|
|
30
|
+
// -------------------------------------------------------------------------
|
|
31
|
+
// Inbound ffmpeg
|
|
32
|
+
//
|
|
33
|
+
// Input 0 – raw mulaw/8kHz from Twilio via stdin
|
|
34
|
+
// Let the raw audio demuxer derive PTS from sample count. Using wall-clock
|
|
35
|
+
// timestamps breaks when ffmpeg drains the buffered startup audio burst.
|
|
36
|
+
// -ch_layout declares mono rather than -ac counting one channel: a count
|
|
37
|
+
// leaves the layout unset, so the decoder guesses it and logs "Guessed
|
|
38
|
+
// Channel Layout: mono" on every call. Our stderr handler is a warn, so
|
|
39
|
+
// that guess looked like a fault in the journal. Needs ffmpeg >= 5.1.
|
|
40
|
+
// -probesize 32 / -analyzeduration 0 skip stream probing: format, rate
|
|
41
|
+
// and layout are all declared, but by default ffmpeg still reads ~3 s of
|
|
42
|
+
// stdin before it outputs anything, then sends that backlog to the
|
|
43
|
+
// controller in one burst, and the caller's first words are lost (#102).
|
|
44
|
+
//
|
|
45
|
+
// Input 1 – synthetic black video (lavfi color source)
|
|
46
|
+
// Generates H.264 Baseline/3.1 frames at 15 fps.
|
|
47
|
+
// HomeKit requires a video track; there is no real camera feed.
|
|
48
|
+
// keyint_min=15 / -g 15 forces an IDR frame every second — HomeKit
|
|
49
|
+
// requests it when the live view is first opened; without frequent IDRs
|
|
50
|
+
// the video stays blank until the next natural keyframe.
|
|
51
|
+
//
|
|
52
|
+
// Two separate SRTP outputs — no muxing, no pts coupling between streams.
|
|
53
|
+
// -------------------------------------------------------------------------
|
|
54
|
+
return [
|
|
55
|
+
'-y',
|
|
56
|
+
'-loglevel',
|
|
57
|
+
'warning',
|
|
58
|
+
|
|
59
|
+
// ---- Input 0: raw mulaw from Twilio ----
|
|
60
|
+
'-thread_queue_size',
|
|
61
|
+
'512',
|
|
62
|
+
'-probesize',
|
|
63
|
+
'32',
|
|
64
|
+
'-analyzeduration',
|
|
65
|
+
'0',
|
|
66
|
+
'-f',
|
|
67
|
+
'mulaw',
|
|
68
|
+
'-ar',
|
|
69
|
+
'8000',
|
|
70
|
+
'-ch_layout',
|
|
71
|
+
'mono',
|
|
72
|
+
'-i',
|
|
73
|
+
'pipe:0',
|
|
74
|
+
|
|
75
|
+
// ---- Input 1: blank video ----
|
|
76
|
+
'-f',
|
|
77
|
+
'lavfi',
|
|
78
|
+
'-i',
|
|
79
|
+
'color=black:s=1280x720:r=15',
|
|
80
|
+
|
|
81
|
+
// ---- Video output → HomeKit SRTP ----
|
|
82
|
+
'-map',
|
|
83
|
+
'1:v',
|
|
84
|
+
'-c:v',
|
|
85
|
+
'libx264',
|
|
86
|
+
'-profile:v',
|
|
87
|
+
'baseline',
|
|
88
|
+
'-level:v',
|
|
89
|
+
'3.1',
|
|
90
|
+
'-preset',
|
|
91
|
+
'ultrafast',
|
|
92
|
+
'-tune',
|
|
93
|
+
'zerolatency',
|
|
94
|
+
'-pix_fmt',
|
|
95
|
+
'yuv420p',
|
|
96
|
+
'-b:v',
|
|
97
|
+
`${videoBitrate}k`,
|
|
98
|
+
'-maxrate',
|
|
99
|
+
`${videoBitrate}k`,
|
|
100
|
+
'-bufsize',
|
|
101
|
+
`${videoBufferSize}k`,
|
|
102
|
+
'-g',
|
|
103
|
+
String(video.fps || 15),
|
|
104
|
+
'-keyint_min',
|
|
105
|
+
String(video.fps || 15),
|
|
106
|
+
'-payload_type',
|
|
107
|
+
String(video.pt),
|
|
108
|
+
'-ssrc',
|
|
109
|
+
String(input.videoSsrc),
|
|
110
|
+
'-f',
|
|
111
|
+
'rtp',
|
|
112
|
+
'-srtp_out_suite',
|
|
113
|
+
'AES_CM_128_HMAC_SHA1_80',
|
|
114
|
+
'-srtp_out_params',
|
|
115
|
+
videoParams,
|
|
116
|
+
`srtp://${input.targetAddress}:${input.hkVideoPort}?rtcpport=${input.hkVideoPort}&localrtcpport=${input.hkVideoPort}&pkt_size=${mtu}`,
|
|
117
|
+
|
|
118
|
+
// ---- Audio output → HomeKit SRTP (Opus/16kHz) ----
|
|
119
|
+
//
|
|
120
|
+
// Note on codec choice: libopus is in every standard ffmpeg build.
|
|
121
|
+
// If you prefer AAC-ELD (required by some older HomeKit devices), compile
|
|
122
|
+
// ffmpeg with --enable-libfdk-aac --enable-nonfree and change:
|
|
123
|
+
// '-c:a', 'libfdk_aac', '-profile:a', 'aac_eld',
|
|
124
|
+
// and update streamingOptions.audio.codecs below to AAC_ELD.
|
|
125
|
+
//
|
|
126
|
+
// RTP clock: HomeKit clocks Opus RTP timestamps at the negotiated sample
|
|
127
|
+
// rate (16 kHz, 320 ticks per 20 ms packet), which is also what the iPhone
|
|
128
|
+
// sends on the return leg. ffmpeg's RTP muxer always uses RFC 7587's
|
|
129
|
+
// 48 kHz for Opus (960 ticks), so the controller saw audio arriving three
|
|
130
|
+
// times slower than its timestamps said, and dropped words (#102).
|
|
131
|
+
// libopus stamps packets in 1/16000; relabelling that time base as
|
|
132
|
+
// 1/48000 stops the muxer rescaling, so each packet advances by 320.
|
|
133
|
+
'-map',
|
|
134
|
+
'0:a',
|
|
135
|
+
'-c:a',
|
|
136
|
+
'libopus',
|
|
137
|
+
'-ar',
|
|
138
|
+
'16000',
|
|
139
|
+
'-ac',
|
|
140
|
+
'1',
|
|
141
|
+
'-b:a',
|
|
142
|
+
'24k',
|
|
143
|
+
'-application',
|
|
144
|
+
'voip',
|
|
145
|
+
'-frame_duration',
|
|
146
|
+
'20',
|
|
147
|
+
'-bsf:a',
|
|
148
|
+
'setts=time_base=1/48000',
|
|
149
|
+
'-payload_type',
|
|
150
|
+
String(audio.pt),
|
|
151
|
+
'-ssrc',
|
|
152
|
+
String(input.audioSsrc),
|
|
153
|
+
'-f',
|
|
154
|
+
'rtp',
|
|
155
|
+
'-srtp_out_suite',
|
|
156
|
+
'AES_CM_128_HMAC_SHA1_80',
|
|
157
|
+
'-srtp_out_params',
|
|
158
|
+
audioParams,
|
|
159
|
+
`srtp://${input.targetAddress}:${input.hkAudioPort}?rtcpport=${input.hkAudioPort}&localrtcpport=${input.hkAudioPort}`,
|
|
160
|
+
];
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
module.exports = { buildInboundFfmpegArgs };
|
package/src/core/media-stream.js
CHANGED
|
@@ -13,7 +13,7 @@ const { PassThrough } = require('stream');
|
|
|
13
13
|
const { parseTwilioWsEvent, parseTwilioMediaPayload } = require('./ws-events-schema');
|
|
14
14
|
const { createRingbackMulawCycle, sendMulawAudio } = require('./mulaw-audio');
|
|
15
15
|
|
|
16
|
-
/** @import {
|
|
16
|
+
/** @import { WebSocket } from 'ws' */
|
|
17
17
|
/** @import { WsEventParseOkSupported, StreamTokenVerificationResult, TokenVerificationError, WsEventParseError, MediaPayloadParseError } from './types' */
|
|
18
18
|
|
|
19
19
|
/**
|
|
@@ -31,7 +31,7 @@ const { createRingbackMulawCycle, sendMulawAudio } = require('./mulaw-audio');
|
|
|
31
31
|
|
|
32
32
|
class MediaStream {
|
|
33
33
|
/**
|
|
34
|
-
* @param {
|
|
34
|
+
* @param {WebSocket} connection
|
|
35
35
|
* @param {MediaStreamDeps} deps
|
|
36
36
|
*/
|
|
37
37
|
constructor(connection, deps) {
|
|
@@ -67,11 +67,12 @@ class MediaStream {
|
|
|
67
67
|
}
|
|
68
68
|
|
|
69
69
|
/**
|
|
70
|
-
* @param {
|
|
70
|
+
* @param {Buffer} data
|
|
71
|
+
* @param {boolean} isBinary
|
|
71
72
|
*/
|
|
72
|
-
processMessage(
|
|
73
|
-
if (
|
|
74
|
-
if (
|
|
73
|
+
processMessage(data, isBinary) {
|
|
74
|
+
if (isBinary) return;
|
|
75
|
+
if (data.length > this.deps.maxUtf8Bytes) {
|
|
75
76
|
this.logger.warn('Media websocket message too large', {
|
|
76
77
|
event: 'media-ws-message-too-large',
|
|
77
78
|
reason: 'max-message-bytes-exceeded',
|
|
@@ -82,7 +83,7 @@ class MediaStream {
|
|
|
82
83
|
|
|
83
84
|
let rawData;
|
|
84
85
|
try {
|
|
85
|
-
rawData = JSON.parse(
|
|
86
|
+
rawData = JSON.parse(data.toString('utf8'));
|
|
86
87
|
} catch {
|
|
87
88
|
this.logger.warn('Media websocket invalid JSON', {
|
|
88
89
|
event: 'media-ws-invalid-json',
|
package/src/core/mulaw-audio.js
CHANGED
|
@@ -174,14 +174,14 @@ function createDualToneMulaw(lowFrequency, highFrequency, durationMs, amplitude)
|
|
|
174
174
|
}
|
|
175
175
|
|
|
176
176
|
/**
|
|
177
|
-
* @param {{ streamSid: string, wsConnection: {
|
|
177
|
+
* @param {{ streamSid: string, wsConnection: { send: (message: string) => void } }} activeCall
|
|
178
178
|
* @param {Buffer} payload
|
|
179
179
|
*/
|
|
180
180
|
function sendMulawAudio(activeCall, payload) {
|
|
181
181
|
if (!activeCall || !activeCall.streamSid || !activeCall.wsConnection) {
|
|
182
182
|
throw new Error('Cannot send audio without an active media stream');
|
|
183
183
|
}
|
|
184
|
-
activeCall.wsConnection.
|
|
184
|
+
activeCall.wsConnection.send(
|
|
185
185
|
JSON.stringify({
|
|
186
186
|
event: 'media',
|
|
187
187
|
streamSid: activeCall.streamSid,
|
|
@@ -191,7 +191,7 @@ function sendMulawAudio(activeCall, payload) {
|
|
|
191
191
|
}
|
|
192
192
|
|
|
193
193
|
/**
|
|
194
|
-
* @param {{ streamSid: string, wsConnection: {
|
|
194
|
+
* @param {{ streamSid: string, wsConnection: { send: (message: string) => void } }} activeCall
|
|
195
195
|
* @param {string} digits
|
|
196
196
|
* @param {{ chunkMs?: number, toneMs?: number, trailingSilenceMs?: number }} [options]
|
|
197
197
|
*/
|
package/src/core/state.js
CHANGED
|
@@ -4,7 +4,7 @@ const config = require('./config');
|
|
|
4
4
|
const { createLogger } = require('./log');
|
|
5
5
|
const logger = createLogger({ component: 'state' });
|
|
6
6
|
|
|
7
|
-
/** @import {
|
|
7
|
+
/** @import { WebSocket as WebSocketConnection } from 'ws' */
|
|
8
8
|
/** @import { CallSession, CallStatus, StartCallResult, ClearCallResult, ClearedCallSession } from './types.js' */
|
|
9
9
|
|
|
10
10
|
class CallSessionManager {
|
package/src/core/types.js
CHANGED