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.
@@ -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
+ };