buzz-me-in 2.0.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/.env.example +68 -0
- package/LICENSE +21 -0
- package/README.md +452 -0
- package/homekit.js +789 -0
- package/intercom.service +48 -0
- package/package.json +73 -0
- package/server.js +420 -0
- package/src/core/config.js +133 -0
- package/src/core/log.js +132 -0
- package/src/core/media-stream.js +335 -0
- package/src/core/mulaw-audio.js +233 -0
- package/src/core/return-audio-sdp.js +95 -0
- package/src/core/safe-equal.js +22 -0
- package/src/core/state.js +149 -0
- package/src/core/stream-auth.js +130 -0
- package/src/core/twilio-ids.js +27 -0
- package/src/core/types.js +145 -0
- package/src/core/ws-events-schema.js +110 -0
- package/twilio-api.js +53 -0
package/src/core/log.js
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/** @import { LogFields } from './types.js' */
|
|
4
|
+
|
|
5
|
+
const LEVELS = {
|
|
6
|
+
debug: 10,
|
|
7
|
+
info: 20,
|
|
8
|
+
warn: 30,
|
|
9
|
+
error: 40,
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const DEFAULT_LEVEL = 'info';
|
|
13
|
+
const PRETTY_LOGS = process.env.LOG_PRETTY === '1';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param {unknown} value
|
|
17
|
+
* @returns {LogFields}
|
|
18
|
+
*/
|
|
19
|
+
function normalizeFields(value) {
|
|
20
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
|
|
21
|
+
|
|
22
|
+
/** @type {LogFields} */
|
|
23
|
+
const normalized = {};
|
|
24
|
+
for (const [key, fieldValue] of Object.entries(value)) {
|
|
25
|
+
if (fieldValue === undefined) continue;
|
|
26
|
+
if (fieldValue instanceof Error) {
|
|
27
|
+
normalized.errorName = fieldValue.name;
|
|
28
|
+
normalized.errorMessage = fieldValue.message;
|
|
29
|
+
normalized.errorStack = fieldValue.stack || '';
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
normalized[key] = fieldValue;
|
|
33
|
+
}
|
|
34
|
+
return normalized;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* @param {unknown} value
|
|
39
|
+
* @returns {'debug' | 'info' | 'warn' | 'error'}
|
|
40
|
+
*/
|
|
41
|
+
function parseLevel(value) {
|
|
42
|
+
if (typeof value !== 'string') return DEFAULT_LEVEL;
|
|
43
|
+
const lower = value.toLowerCase();
|
|
44
|
+
if (lower === 'debug' || lower === 'info' || lower === 'warn' || lower === 'error') {
|
|
45
|
+
return lower;
|
|
46
|
+
}
|
|
47
|
+
return DEFAULT_LEVEL;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const ACTIVE_LEVEL = parseLevel(process.env.LOG_LEVEL);
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* @param {'debug' | 'info' | 'warn' | 'error'} level
|
|
54
|
+
* @returns {boolean}
|
|
55
|
+
*/
|
|
56
|
+
function shouldLog(level) {
|
|
57
|
+
return LEVELS[level] >= LEVELS[ACTIVE_LEVEL];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* @param {'debug' | 'info' | 'warn' | 'error'} level
|
|
62
|
+
* @param {LogFields} payload
|
|
63
|
+
*/
|
|
64
|
+
function emit(level, payload) {
|
|
65
|
+
const target = level === 'warn' || level === 'error' ? process.stderr : process.stdout;
|
|
66
|
+
try {
|
|
67
|
+
const line = PRETTY_LOGS ? JSON.stringify(payload, null, 2) : JSON.stringify(payload);
|
|
68
|
+
target.write(`${line}\n`);
|
|
69
|
+
} catch (err) {
|
|
70
|
+
const fallback = {
|
|
71
|
+
ts: new Date().toISOString(),
|
|
72
|
+
level: 'error',
|
|
73
|
+
message: 'Failed to serialize log payload',
|
|
74
|
+
component: 'log',
|
|
75
|
+
reason: 'serialization-error',
|
|
76
|
+
errorMessage: err instanceof Error ? err.message : String(err),
|
|
77
|
+
errorStack: err instanceof Error && err.stack ? err.stack : '',
|
|
78
|
+
};
|
|
79
|
+
process.stderr.write(`${JSON.stringify(fallback)}\n`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* @param {LogFields} [baseFields]
|
|
85
|
+
*/
|
|
86
|
+
function createLogger(baseFields) {
|
|
87
|
+
const base = normalizeFields(baseFields);
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* @param {'debug' | 'info' | 'warn' | 'error'} level
|
|
91
|
+
* @param {string} message
|
|
92
|
+
* @param {LogFields} [fields]
|
|
93
|
+
*/
|
|
94
|
+
function logAt(level, message, fields) {
|
|
95
|
+
if (!shouldLog(level)) return;
|
|
96
|
+
const payload = {
|
|
97
|
+
ts: new Date().toISOString(),
|
|
98
|
+
level,
|
|
99
|
+
message,
|
|
100
|
+
...base,
|
|
101
|
+
...normalizeFields(fields),
|
|
102
|
+
};
|
|
103
|
+
emit(level, payload);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
/** @param {string} message @param {LogFields} [fields] */
|
|
108
|
+
debug(message, fields) {
|
|
109
|
+
logAt('debug', message, fields);
|
|
110
|
+
},
|
|
111
|
+
/** @param {string} message @param {LogFields} [fields] */
|
|
112
|
+
info(message, fields) {
|
|
113
|
+
logAt('info', message, fields);
|
|
114
|
+
},
|
|
115
|
+
/** @param {string} message @param {LogFields} [fields] */
|
|
116
|
+
warn(message, fields) {
|
|
117
|
+
logAt('warn', message, fields);
|
|
118
|
+
},
|
|
119
|
+
/** @param {string} message @param {LogFields} [fields] */
|
|
120
|
+
error(message, fields) {
|
|
121
|
+
logAt('error', message, fields);
|
|
122
|
+
},
|
|
123
|
+
/** @param {LogFields} fields */
|
|
124
|
+
child(fields) {
|
|
125
|
+
return createLogger({ ...base, ...normalizeFields(fields) });
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
module.exports = {
|
|
131
|
+
createLogger,
|
|
132
|
+
};
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Twilio media stream session: one instance per accepted WebSocket
|
|
5
|
+
* connection. Handles the Twilio event protocol (connected/start/media/stop),
|
|
6
|
+
* stream token verification, ringback, and teardown.
|
|
7
|
+
*
|
|
8
|
+
* External integrations (call state, HomeKit, logging) are injected so the
|
|
9
|
+
* protocol logic can be unit-tested with fakes.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const { PassThrough } = require('stream');
|
|
13
|
+
const { parseTwilioWsEvent, parseTwilioMediaPayload } = require('./ws-events-schema');
|
|
14
|
+
const { createRingbackMulawCycle, sendMulawAudio } = require('./mulaw-audio');
|
|
15
|
+
|
|
16
|
+
/** @import { connection, Message } from 'websocket' */
|
|
17
|
+
/** @import { WsEventParseOkSupported, StreamTokenVerificationResult, TokenVerificationError, WsEventParseError, MediaPayloadParseError } from './types' */
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @typedef {object} MediaStreamDeps
|
|
21
|
+
* @property {ReturnType<typeof import('./log').createLogger>} logger
|
|
22
|
+
* @property {typeof import('./state')} state
|
|
23
|
+
* @property {{ setMulawPassthrough: (stream: PassThrough) => number, triggerDoorbell: () => void, clearMulawPassthrough: (stream: PassThrough) => void, endHapSession: () => void }} homekit
|
|
24
|
+
* @property {Map<string, MediaStream>} registry map of callSid → MediaStream
|
|
25
|
+
* @property {(token: string) => StreamTokenVerificationResult} verifyStreamToken
|
|
26
|
+
* @property {string} tokenParameterName
|
|
27
|
+
* @property {number} startTimeoutMs
|
|
28
|
+
* @property {number} maxUtf8Bytes
|
|
29
|
+
* @property {number} mediaPayloadMaxBytes
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
class MediaStream {
|
|
33
|
+
/**
|
|
34
|
+
* @param {connection} connection
|
|
35
|
+
* @param {MediaStreamDeps} deps
|
|
36
|
+
*/
|
|
37
|
+
constructor(connection, deps) {
|
|
38
|
+
this.connection = connection;
|
|
39
|
+
this.deps = deps;
|
|
40
|
+
this.logger = deps.logger;
|
|
41
|
+
this.messageCount = 0;
|
|
42
|
+
this.currentCallSid = null;
|
|
43
|
+
this.started = false;
|
|
44
|
+
this.closed = false;
|
|
45
|
+
this.hasHomekitSession = false;
|
|
46
|
+
this.droppingFrames = false;
|
|
47
|
+
this.ringbackTimer = null;
|
|
48
|
+
this.ringbackOffset = 0;
|
|
49
|
+
this.ringbackPayload = createRingbackMulawCycle();
|
|
50
|
+
this.startTimeout = setTimeout(() => {
|
|
51
|
+
if (!this.started) {
|
|
52
|
+
this.logger.warn('Media websocket start timed out', {
|
|
53
|
+
event: 'start',
|
|
54
|
+
reason: 'start-timeout',
|
|
55
|
+
});
|
|
56
|
+
this.connection.close();
|
|
57
|
+
}
|
|
58
|
+
}, deps.startTimeoutMs);
|
|
59
|
+
this.startTimeout.unref();
|
|
60
|
+
|
|
61
|
+
// Raw mulaw bytes from Twilio flow into this PassThrough only after
|
|
62
|
+
// HomeKit opens a live view. Pre-live buffering creates catch-up latency.
|
|
63
|
+
this.mulawStream = new PassThrough({ highWaterMark: 32768 });
|
|
64
|
+
|
|
65
|
+
connection.on('message', this.processMessage.bind(this));
|
|
66
|
+
connection.on('close', this.close.bind(this));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* @param {Message} message
|
|
71
|
+
*/
|
|
72
|
+
processMessage(message) {
|
|
73
|
+
if (message.type !== 'utf8') return;
|
|
74
|
+
if (message.utf8Data.length > this.deps.maxUtf8Bytes) {
|
|
75
|
+
this.logger.warn('Media websocket message too large', {
|
|
76
|
+
event: 'media-ws-message-too-large',
|
|
77
|
+
reason: 'max-message-bytes-exceeded',
|
|
78
|
+
});
|
|
79
|
+
this.connection.close();
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
let rawData;
|
|
84
|
+
try {
|
|
85
|
+
rawData = JSON.parse(message.utf8Data);
|
|
86
|
+
} catch {
|
|
87
|
+
this.logger.warn('Media websocket invalid JSON', {
|
|
88
|
+
event: 'media-ws-invalid-json',
|
|
89
|
+
reason: 'json-parse-failed',
|
|
90
|
+
});
|
|
91
|
+
this.connection.close();
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const parsedResult = parseTwilioWsEvent(rawData);
|
|
96
|
+
if (!parsedResult.ok) {
|
|
97
|
+
const parsedError = /** @type {WsEventParseError} */ (parsedResult);
|
|
98
|
+
this.logger.warn('Media websocket invalid event payload', {
|
|
99
|
+
event: 'media-ws-invalid-event-payload',
|
|
100
|
+
reason: parsedError.reason,
|
|
101
|
+
});
|
|
102
|
+
this.connection.close();
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (parsedResult.unsupported) {
|
|
106
|
+
this.logger.info('Media websocket unsupported event ignored', {
|
|
107
|
+
event: 'media-ws-unsupported-event',
|
|
108
|
+
reason: parsedResult.event,
|
|
109
|
+
});
|
|
110
|
+
this.messageCount++;
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
const parsed = /** @type {WsEventParseOkSupported} */ (parsedResult);
|
|
114
|
+
|
|
115
|
+
switch (parsed.event) {
|
|
116
|
+
case 'connected': {
|
|
117
|
+
this.logger.info('Media websocket connected event', {
|
|
118
|
+
event: 'connected',
|
|
119
|
+
});
|
|
120
|
+
break;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
case 'start': {
|
|
124
|
+
this._handleStart(parsed.data.start);
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
case 'media': {
|
|
129
|
+
this._handleMedia(parsed.data.media);
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
case 'stop': {
|
|
134
|
+
this.logger.info('Media websocket stop event', {
|
|
135
|
+
callSid: this.currentCallSid || undefined,
|
|
136
|
+
event: 'stop',
|
|
137
|
+
});
|
|
138
|
+
this._teardown('twilio-stop');
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
this.messageCount++;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* @param {{ callSid: string, streamSid: string, customParameters?: Record<string, string> }} start
|
|
148
|
+
*/
|
|
149
|
+
_handleStart(start) {
|
|
150
|
+
if (this.started) {
|
|
151
|
+
this.logger.warn('Duplicate media websocket start event', {
|
|
152
|
+
event: 'start',
|
|
153
|
+
reason: 'duplicate-start',
|
|
154
|
+
});
|
|
155
|
+
this.connection.close();
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
const token = start.customParameters
|
|
159
|
+
? start.customParameters[this.deps.tokenParameterName]
|
|
160
|
+
: undefined;
|
|
161
|
+
const verification = this.deps.verifyStreamToken(typeof token === 'string' ? token : '');
|
|
162
|
+
if (!verification.ok) {
|
|
163
|
+
const verificationError = /** @type {TokenVerificationError} */ (verification);
|
|
164
|
+
this.logger.warn('Media websocket start rejected', {
|
|
165
|
+
callSid: start.callSid,
|
|
166
|
+
event: 'start',
|
|
167
|
+
reason: verificationError.reason,
|
|
168
|
+
});
|
|
169
|
+
this.connection.close();
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
if (verification.callSid && verification.callSid !== start.callSid) {
|
|
173
|
+
this.logger.warn('Media websocket start rejected', {
|
|
174
|
+
callSid: start.callSid,
|
|
175
|
+
event: 'start',
|
|
176
|
+
reason: 'callsid-mismatch',
|
|
177
|
+
});
|
|
178
|
+
this.connection.close();
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
const started = this.deps.state.startCall({
|
|
182
|
+
callSid: start.callSid,
|
|
183
|
+
streamSid: start.streamSid,
|
|
184
|
+
wsConnection: this.connection,
|
|
185
|
+
});
|
|
186
|
+
if (started.ok === false) {
|
|
187
|
+
this.logger.warn('Media websocket start rejected', {
|
|
188
|
+
callSid: start.callSid,
|
|
189
|
+
event: 'start',
|
|
190
|
+
reason: started.reason,
|
|
191
|
+
});
|
|
192
|
+
this.connection.close();
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
this.logger.info('Media websocket start accepted', {
|
|
196
|
+
callSid: start.callSid,
|
|
197
|
+
event: 'start',
|
|
198
|
+
streamSid: start.streamSid,
|
|
199
|
+
});
|
|
200
|
+
clearTimeout(this.startTimeout);
|
|
201
|
+
this.currentCallSid = start.callSid;
|
|
202
|
+
this.started = true;
|
|
203
|
+
this.deps.registry.set(start.callSid, this);
|
|
204
|
+
const reboundSessionCount = this.deps.homekit.setMulawPassthrough(this.mulawStream);
|
|
205
|
+
if (reboundSessionCount > 0) {
|
|
206
|
+
this.markHomekitSessionStarted();
|
|
207
|
+
} else {
|
|
208
|
+
this.deps.homekit.triggerDoorbell();
|
|
209
|
+
this.startRingback();
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* @param {{ payload: string }} media
|
|
215
|
+
*/
|
|
216
|
+
_handleMedia(media) {
|
|
217
|
+
if (!this.started || !this.currentCallSid) {
|
|
218
|
+
this.logger.warn('Media frame before start event', {
|
|
219
|
+
event: 'media',
|
|
220
|
+
reason: 'media-before-start',
|
|
221
|
+
});
|
|
222
|
+
this.connection.close();
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
const mediaPayload = parseTwilioMediaPayload(media.payload, this.deps.mediaPayloadMaxBytes);
|
|
226
|
+
if (!mediaPayload.ok) {
|
|
227
|
+
const mediaPayloadError = /** @type {MediaPayloadParseError} */ (mediaPayload);
|
|
228
|
+
this.logger.warn('Media payload rejected', {
|
|
229
|
+
callSid: this.currentCallSid,
|
|
230
|
+
event: 'media',
|
|
231
|
+
reason: mediaPayloadError.reason,
|
|
232
|
+
});
|
|
233
|
+
this.connection.close();
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
if (this.hasHomekitSession) {
|
|
237
|
+
// Only forward live-view audio. Buffering pre-answer audio adds seconds
|
|
238
|
+
// of catch-up latency when HomeKit finally starts ffmpeg.
|
|
239
|
+
// Drop frames while the buffer needs draining: for live audio,
|
|
240
|
+
// unbounded queueing behind a stalled ffmpeg is worse than a gap.
|
|
241
|
+
if (this.mulawStream.writableNeedDrain) {
|
|
242
|
+
if (!this.droppingFrames) {
|
|
243
|
+
this.droppingFrames = true;
|
|
244
|
+
this.logger.warn('Dropping media frames; mulaw buffer is full', {
|
|
245
|
+
callSid: this.currentCallSid,
|
|
246
|
+
event: 'media-frames-dropped',
|
|
247
|
+
reason: 'mulaw-buffer-full',
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
} else {
|
|
251
|
+
if (this.droppingFrames) {
|
|
252
|
+
this.droppingFrames = false;
|
|
253
|
+
this.logger.info('Resumed forwarding media frames', {
|
|
254
|
+
callSid: this.currentCallSid,
|
|
255
|
+
event: 'media-frames-resumed',
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
this.mulawStream.write(mediaPayload.decoded);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
this.deps.state.markActivity(this.currentCallSid, 'twilio-media');
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
close() {
|
|
265
|
+
this._teardown('ws-close');
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
startRingback() {
|
|
269
|
+
if (this.ringbackTimer || !this.currentCallSid) return;
|
|
270
|
+
this.ringbackTimer = setInterval(() => {
|
|
271
|
+
if (this.closed || !this.currentCallSid) {
|
|
272
|
+
this.stopRingback('stream-ended');
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
this.sendRingbackFrame();
|
|
276
|
+
}, 20);
|
|
277
|
+
if (typeof this.ringbackTimer.unref === 'function') this.ringbackTimer.unref();
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
stopRingback(reason) {
|
|
281
|
+
if (!this.ringbackTimer) return;
|
|
282
|
+
clearInterval(this.ringbackTimer);
|
|
283
|
+
this.ringbackTimer = null;
|
|
284
|
+
this.logger.info('Ringback media stopped', {
|
|
285
|
+
callSid: this.currentCallSid || undefined,
|
|
286
|
+
event: 'ringback-stopped',
|
|
287
|
+
reason,
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
markHomekitSessionStarted() {
|
|
292
|
+
this.hasHomekitSession = true;
|
|
293
|
+
this.stopRingback('homekit-session-started');
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
sendRingbackFrame() {
|
|
297
|
+
if (!this.currentCallSid || !this.started || this.closed) return;
|
|
298
|
+
|
|
299
|
+
const chunkSize = 160; // 20 ms of 8 kHz mu-law audio.
|
|
300
|
+
const chunk = Buffer.alloc(chunkSize);
|
|
301
|
+
for (let i = 0; i < chunkSize; i++) {
|
|
302
|
+
chunk[i] = this.ringbackPayload[this.ringbackOffset];
|
|
303
|
+
this.ringbackOffset = (this.ringbackOffset + 1) % this.ringbackPayload.length;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const activeCall = this.deps.state.getActiveCall();
|
|
307
|
+
if (!activeCall || activeCall.wsConnection !== this.connection || !activeCall.streamSid) return;
|
|
308
|
+
sendMulawAudio(activeCall, chunk);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
_teardown(reason) {
|
|
312
|
+
if (this.closed) return;
|
|
313
|
+
this.closed = true;
|
|
314
|
+
this.stopRingback(reason);
|
|
315
|
+
this.logger.info('Media websocket session ended', {
|
|
316
|
+
callSid: this.currentCallSid || undefined,
|
|
317
|
+
event: 'session-ended',
|
|
318
|
+
reason,
|
|
319
|
+
messageCount: this.messageCount,
|
|
320
|
+
});
|
|
321
|
+
clearTimeout(this.startTimeout);
|
|
322
|
+
// Guard: close() can fire without a prior 'stop' event (e.g. network drop).
|
|
323
|
+
this.deps.homekit.clearMulawPassthrough(this.mulawStream);
|
|
324
|
+
this.mulawStream.destroy();
|
|
325
|
+
const { cleared } = this.deps.state.clearIfConnection(this.connection, reason);
|
|
326
|
+
if (this.currentCallSid && this.deps.registry.get(this.currentCallSid) === this) {
|
|
327
|
+
this.deps.registry.delete(this.currentCallSid);
|
|
328
|
+
}
|
|
329
|
+
if (cleared) {
|
|
330
|
+
this.deps.homekit.endHapSession();
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
module.exports = { MediaStream };
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const SAMPLE_RATE_HZ = 8000;
|
|
4
|
+
const DEFAULT_DTMF_TONE_MS = 650;
|
|
5
|
+
const DEFAULT_DTMF_TRAILING_SILENCE_MS = 120;
|
|
6
|
+
const DEFAULT_CHUNK_MS = 20;
|
|
7
|
+
const RINGBACK_BURST_MS = 400;
|
|
8
|
+
const RINGBACK_GAP_MS = 200;
|
|
9
|
+
const RINGBACK_TAIL_MS = 2000;
|
|
10
|
+
const MULAW_BIAS = 0x84;
|
|
11
|
+
const MULAW_CLIP = 32635;
|
|
12
|
+
|
|
13
|
+
const DTMF_FREQUENCIES = {
|
|
14
|
+
1: [697, 1209],
|
|
15
|
+
2: [697, 1336],
|
|
16
|
+
3: [697, 1477],
|
|
17
|
+
A: [697, 1633],
|
|
18
|
+
4: [770, 1209],
|
|
19
|
+
5: [770, 1336],
|
|
20
|
+
6: [770, 1477],
|
|
21
|
+
B: [770, 1633],
|
|
22
|
+
7: [852, 1209],
|
|
23
|
+
8: [852, 1336],
|
|
24
|
+
9: [852, 1477],
|
|
25
|
+
C: [852, 1633],
|
|
26
|
+
'*': [941, 1209],
|
|
27
|
+
0: [941, 1336],
|
|
28
|
+
'#': [941, 1477],
|
|
29
|
+
D: [941, 1633],
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
function sleep(ms) {
|
|
33
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @param {number} sample
|
|
38
|
+
* @returns {number}
|
|
39
|
+
*/
|
|
40
|
+
function linear16ToMulaw(sample) {
|
|
41
|
+
let sign = 0;
|
|
42
|
+
let magnitude = Math.round(sample);
|
|
43
|
+
|
|
44
|
+
if (magnitude < 0) {
|
|
45
|
+
sign = 0x80;
|
|
46
|
+
magnitude = -magnitude;
|
|
47
|
+
}
|
|
48
|
+
if (magnitude > MULAW_CLIP) magnitude = MULAW_CLIP;
|
|
49
|
+
|
|
50
|
+
magnitude += MULAW_BIAS;
|
|
51
|
+
|
|
52
|
+
let exponent = 7;
|
|
53
|
+
for (let mask = 0x4000; exponent > 0 && (magnitude & mask) === 0; mask >>= 1) {
|
|
54
|
+
exponent--;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const mantissa = (magnitude >> (exponent + 3)) & 0x0f;
|
|
58
|
+
return ~(sign | (exponent << 4) | mantissa) & 0xff;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Generate a Twilio-compatible raw mu-law/8kHz DTMF tone.
|
|
63
|
+
*
|
|
64
|
+
* @param {string | number} digit
|
|
65
|
+
* @param {{ toneMs?: number, trailingSilenceMs?: number, amplitude?: number }} [options]
|
|
66
|
+
* @returns {Buffer}
|
|
67
|
+
*/
|
|
68
|
+
function createDtmfMulawTone(digit, options = {}) {
|
|
69
|
+
const normalizedDigit = String(digit).toUpperCase();
|
|
70
|
+
const frequencies = DTMF_FREQUENCIES[normalizedDigit];
|
|
71
|
+
if (!frequencies) {
|
|
72
|
+
throw new Error(`Unsupported DTMF digit: ${digit}`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const toneMs = options.toneMs ?? DEFAULT_DTMF_TONE_MS;
|
|
76
|
+
const trailingSilenceMs = options.trailingSilenceMs ?? DEFAULT_DTMF_TRAILING_SILENCE_MS;
|
|
77
|
+
const amplitude = options.amplitude ?? 12000;
|
|
78
|
+
const toneSamples = Math.round((SAMPLE_RATE_HZ * toneMs) / 1000);
|
|
79
|
+
const silenceSamples = Math.round((SAMPLE_RATE_HZ * trailingSilenceMs) / 1000);
|
|
80
|
+
const payload = Buffer.alloc(toneSamples + silenceSamples, 0xff);
|
|
81
|
+
const [lowFrequency, highFrequency] = frequencies;
|
|
82
|
+
|
|
83
|
+
for (let i = 0; i < toneSamples; i++) {
|
|
84
|
+
const t = i / SAMPLE_RATE_HZ;
|
|
85
|
+
const sample =
|
|
86
|
+
(amplitude *
|
|
87
|
+
(Math.sin(2 * Math.PI * lowFrequency * t) + Math.sin(2 * Math.PI * highFrequency * t))) /
|
|
88
|
+
2;
|
|
89
|
+
payload[i] = linear16ToMulaw(sample);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return payload;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Inverse of {@link linear16ToMulaw}.
|
|
97
|
+
*
|
|
98
|
+
* @param {number} mulawByte
|
|
99
|
+
* @returns {number}
|
|
100
|
+
*/
|
|
101
|
+
function mulawToLinear16(mulawByte) {
|
|
102
|
+
const u = ~mulawByte & 0xff;
|
|
103
|
+
const sign = u & 0x80;
|
|
104
|
+
const exponent = (u >> 4) & 0x07;
|
|
105
|
+
const mantissa = u & 0x0f;
|
|
106
|
+
const magnitude = (((mantissa << 3) + MULAW_BIAS) << exponent) - MULAW_BIAS;
|
|
107
|
+
return sign ? -magnitude : magnitude;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* One ringback cycle as a PCM16/8kHz mono WAV file, for serving the
|
|
112
|
+
* ringtone over HTTP without shelling out to ffmpeg.
|
|
113
|
+
*
|
|
114
|
+
* @returns {Buffer}
|
|
115
|
+
*/
|
|
116
|
+
function createRingbackWav() {
|
|
117
|
+
const mulaw = createRingbackMulawCycle();
|
|
118
|
+
const pcm = Buffer.alloc(mulaw.length * 2);
|
|
119
|
+
for (let i = 0; i < mulaw.length; i++) {
|
|
120
|
+
pcm.writeInt16LE(mulawToLinear16(mulaw[i]), i * 2);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const header = Buffer.alloc(44);
|
|
124
|
+
header.write('RIFF', 0, 'ascii');
|
|
125
|
+
header.writeUInt32LE(36 + pcm.length, 4);
|
|
126
|
+
header.write('WAVE', 8, 'ascii');
|
|
127
|
+
header.write('fmt ', 12, 'ascii');
|
|
128
|
+
header.writeUInt32LE(16, 16); // fmt chunk size
|
|
129
|
+
header.writeUInt16LE(1, 20); // PCM
|
|
130
|
+
header.writeUInt16LE(1, 22); // mono
|
|
131
|
+
header.writeUInt32LE(SAMPLE_RATE_HZ, 24);
|
|
132
|
+
header.writeUInt32LE(SAMPLE_RATE_HZ * 2, 28); // byte rate
|
|
133
|
+
header.writeUInt16LE(2, 32); // block align
|
|
134
|
+
header.writeUInt16LE(16, 34); // bits per sample
|
|
135
|
+
header.write('data', 36, 'ascii');
|
|
136
|
+
header.writeUInt32LE(pcm.length, 40);
|
|
137
|
+
return Buffer.concat([header, pcm]);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Generate one UK-style ringback cycle as raw mu-law/8kHz audio:
|
|
142
|
+
* 400 ms tone, 200 ms silence, 400 ms tone, 2000 ms silence.
|
|
143
|
+
*
|
|
144
|
+
* @returns {Buffer}
|
|
145
|
+
*/
|
|
146
|
+
function createRingbackMulawCycle() {
|
|
147
|
+
const burst = createDualToneMulaw(400, 450, RINGBACK_BURST_MS, 9000);
|
|
148
|
+
const gap = Buffer.alloc(Math.round((SAMPLE_RATE_HZ * RINGBACK_GAP_MS) / 1000), 0xff);
|
|
149
|
+
const tail = Buffer.alloc(Math.round((SAMPLE_RATE_HZ * RINGBACK_TAIL_MS) / 1000), 0xff);
|
|
150
|
+
return Buffer.concat([burst, gap, burst, tail]);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* @param {number} lowFrequency
|
|
155
|
+
* @param {number} highFrequency
|
|
156
|
+
* @param {number} durationMs
|
|
157
|
+
* @param {number} amplitude
|
|
158
|
+
* @returns {Buffer}
|
|
159
|
+
*/
|
|
160
|
+
function createDualToneMulaw(lowFrequency, highFrequency, durationMs, amplitude) {
|
|
161
|
+
const samples = Math.round((SAMPLE_RATE_HZ * durationMs) / 1000);
|
|
162
|
+
const payload = Buffer.alloc(samples);
|
|
163
|
+
|
|
164
|
+
for (let i = 0; i < samples; i++) {
|
|
165
|
+
const t = i / SAMPLE_RATE_HZ;
|
|
166
|
+
const sample =
|
|
167
|
+
(amplitude *
|
|
168
|
+
(Math.sin(2 * Math.PI * lowFrequency * t) + Math.sin(2 * Math.PI * highFrequency * t))) /
|
|
169
|
+
2;
|
|
170
|
+
payload[i] = linear16ToMulaw(sample);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return payload;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* @param {{ streamSid: string, wsConnection: { sendUTF: (message: string) => void } }} activeCall
|
|
178
|
+
* @param {Buffer} payload
|
|
179
|
+
*/
|
|
180
|
+
function sendMulawAudio(activeCall, payload) {
|
|
181
|
+
if (!activeCall || !activeCall.streamSid || !activeCall.wsConnection) {
|
|
182
|
+
throw new Error('Cannot send audio without an active media stream');
|
|
183
|
+
}
|
|
184
|
+
activeCall.wsConnection.sendUTF(
|
|
185
|
+
JSON.stringify({
|
|
186
|
+
event: 'media',
|
|
187
|
+
streamSid: activeCall.streamSid,
|
|
188
|
+
media: { payload: payload.toString('base64') },
|
|
189
|
+
})
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* @param {{ streamSid: string, wsConnection: { sendUTF: (message: string) => void } }} activeCall
|
|
195
|
+
* @param {string} digits
|
|
196
|
+
* @param {{ chunkMs?: number, toneMs?: number, trailingSilenceMs?: number }} [options]
|
|
197
|
+
*/
|
|
198
|
+
async function sendDtmfSequence(activeCall, digits, options = {}) {
|
|
199
|
+
if (!activeCall || !activeCall.streamSid || !activeCall.wsConnection) {
|
|
200
|
+
throw new Error('Cannot send DTMF without an active media stream');
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const chunkMs = options.chunkMs ?? DEFAULT_CHUNK_MS;
|
|
204
|
+
const chunkSize = Math.max(1, Math.round((SAMPLE_RATE_HZ * chunkMs) / 1000));
|
|
205
|
+
|
|
206
|
+
for (const digit of String(digits)) {
|
|
207
|
+
if (digit === 'w') {
|
|
208
|
+
await sleep(500);
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
if (digit === 'W') {
|
|
212
|
+
await sleep(1000);
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const payload = createDtmfMulawTone(digit, options);
|
|
217
|
+
for (let offset = 0; offset < payload.length; offset += chunkSize) {
|
|
218
|
+
const chunk = payload.subarray(offset, offset + chunkSize);
|
|
219
|
+
sendMulawAudio(activeCall, chunk);
|
|
220
|
+
await sleep(chunkMs);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
module.exports = {
|
|
226
|
+
createDtmfMulawTone,
|
|
227
|
+
createRingbackMulawCycle,
|
|
228
|
+
createRingbackWav,
|
|
229
|
+
linear16ToMulaw,
|
|
230
|
+
mulawToLinear16,
|
|
231
|
+
sendDtmfSequence,
|
|
232
|
+
sendMulawAudio,
|
|
233
|
+
};
|