@jambonz/mrf 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,531 @@
1
+ const { EventEmitter } = require('events');
2
+ const { parseSdp, translatePlayUrl } = require('./utils');
3
+
4
+ // mediajam normalized wire events -> legacy ESL event names the
5
+ // feature-server registers via addCustomEventListener
6
+
7
+ /**
8
+ * Endpoint exposes the drachtio-fsmrf Endpoint API surface (the subset the
9
+ * jambonz feature-server uses) over the mediajam control protocol.
10
+ *
11
+ * Events: 'destroy', 'dtmf' ({dtmf, duration, source}), 'playback-start',
12
+ * 'playback-stop', plus custom events registered via addCustomEventListener.
13
+ */
14
+ class Endpoint extends EventEmitter {
15
+ constructor(mediaserver, endpointId, localSdp, remoteSdp) {
16
+ super();
17
+ this.ms = mediaserver;
18
+ this.mediaserver = mediaserver;
19
+ this.uuid = endpointId;
20
+ this.connected = true;
21
+ this.muted = false;
22
+ this.inbandDtmfEnabled = false;
23
+
24
+ this.local = parseSdp(localSdp);
25
+ this.remote = remoteSdp ? parseSdp(remoteSdp) : { sdp: null, mediaIp: null, mediaPort: null };
26
+
27
+ this._customEventListeners = new Map();
28
+ this._pendingPlays = new Map();
29
+ this._channelVars = {};
30
+ }
31
+
32
+ get conn() {
33
+ return this.ms.conn;
34
+ }
35
+
36
+ _request(cmd, data) {
37
+ return this.ms._connection.request(cmd, this.uuid, data);
38
+ }
39
+
40
+ /** Play one or more files/urls; resolves when playback completes.
41
+ * Accepts fsmrf's PlaybackOptions object form ({file, seekOffset}),
42
+ * where seekOffset is in samples at the endpoint sample rate. */
43
+ async play(file) {
44
+ let seekOffset;
45
+ if (!Array.isArray(file) && typeof file === 'object') {
46
+ seekOffset = file.seekOffset;
47
+ file = file.file;
48
+ }
49
+ const urls = (Array.isArray(file) ? file : [file]).map(translatePlayUrl);
50
+ const data = { urls };
51
+ if (seekOffset > 0) data.seekOffset = parseInt(seekOffset, 10);
52
+ const { playId } = await this._request('play.start', data);
53
+ return new Promise((resolve, reject) => {
54
+ this._pendingPlays.set(playId, { resolve, reject });
55
+ });
56
+ }
57
+
58
+ /** fsmrf api() passthrough: translate the FS api commands in use. */
59
+ async api(command, args) {
60
+ const arr = Array.isArray(args) ? args : (args ? String(args).split(' ') : []);
61
+ /* uuid_<vendor>_noise_isolation / uuid_<vendor>_turn_taking: the
62
+ * noise-isolation and turn-taking task interfaces (space-delimited:
63
+ * '<uuid> start <direction> [level] [model]' / '<uuid> start
64
+ * [threshold] [model]'). */
65
+ const ni = /^uuid_([a-z0-9]+)_noise_isolation$/.exec(command);
66
+ if (ni) {
67
+ try {
68
+ if (arr[1] === 'start') {
69
+ await this._request('noise.start', {
70
+ vendor: ni[1],
71
+ ...(arr[2] && {direction: arr[2]}),
72
+ ...(arr[3] && {level: parseInt(arr[3], 10)}),
73
+ ...(arr[4] && {model: arr[4]})
74
+ });
75
+ } else {
76
+ await this._request('noise.stop', {});
77
+ }
78
+ return { body: '+OK' };
79
+ } catch (err) {
80
+ return { body: `-ERR ${err.message}` };
81
+ }
82
+ }
83
+ const tt2 = /^uuid_([a-z0-9]+)_turn_taking$/.exec(command);
84
+ if (tt2) {
85
+ try {
86
+ if (arr[1] === 'start') {
87
+ /* 'interrupt[=<threshold>]' may appear anywhere after 'start':
88
+ * it enables the vendor's interruption-prediction model
89
+ * (mediajam extension; the FS modules predate it) */
90
+ const positional = [];
91
+ let interrupt, interruptThreshold;
92
+ for (const tok of arr.slice(2)) {
93
+ const m = /^interrupt(?:=([\d.]+))?$/.exec(tok);
94
+ if (m) {
95
+ interrupt = true;
96
+ if (m[1]) interruptThreshold = parseFloat(m[1]);
97
+ } else {
98
+ positional.push(tok);
99
+ }
100
+ }
101
+ await this._request('tt.start', {
102
+ vendor: tt2[1],
103
+ ...(positional[0] && {threshold: parseFloat(positional[0])}),
104
+ ...(positional[1] && {model: positional[1]}),
105
+ ...(interrupt && {interrupt}),
106
+ ...(interruptThreshold && {interruptThreshold})
107
+ });
108
+ } else {
109
+ await this._request('tt.stop', {});
110
+ }
111
+ return { body: '+OK' };
112
+ } catch (err) {
113
+ return { body: `-ERR ${err.message}` };
114
+ }
115
+ }
116
+ /* uuid_openai_s2s / uuid_voice_agent_s2s: the llm task interface.
117
+ * args arrive '^^|<uuid>|<command>[|...]'; map to s2s.* commands. */
118
+ const s2 = /^uuid_(openai|voice_agent)_s2s$/.exec(command);
119
+ if (s2) {
120
+ const vendor = s2[1];
121
+ const raw = Array.isArray(args) ? args.join('|') : String(args || '');
122
+ const parts = raw.replace(/^\^\^\|/, '').split('|');
123
+ const cmd = parts[1];
124
+ try {
125
+ switch (cmd) {
126
+ case 'session.create':
127
+ await this._request('s2s.start', {
128
+ vendor, host: parts[2], path: parts[3], authType: parts[4], apiKey: parts[5]
129
+ });
130
+ return { body: '+OK' };
131
+ case 'client.event': {
132
+ const event = JSON.parse(parts.slice(2).join('|'));
133
+ await this._request('s2s.clientEvent', { event });
134
+ return { body: '+OK' };
135
+ }
136
+ case 'session.delete':
137
+ await this._request('s2s.stop', {});
138
+ return { body: '+OK' };
139
+ default:
140
+ return { body: `-ERR unknown s2s command ${cmd}` };
141
+ }
142
+ } catch (err) {
143
+ return { body: `-ERR ${err.message}` };
144
+ }
145
+ }
146
+ /* uuid_<vendor>_tts_streaming: the TtsStreamingBuffer token interface.
147
+ * args arrive '^^|<uuid>|<command>[|<text>]' (FS multi-char delimiter
148
+ * convention); map to the mediajam tts.* command set. */
149
+ const tt = /^uuid_([a-z0-9]+)_tts_streaming$/.exec(command);
150
+ if (tt) {
151
+ const vendor = tt[1];
152
+ const raw = Array.isArray(args) ? args.join('|') : String(args || '');
153
+ const parts = raw.replace(/^\^\^\|/, '').split('|').map((p) => p.trim());
154
+ const cmd = parts[1];
155
+ const text = parts.slice(2).join('|');
156
+ try {
157
+ switch (cmd) {
158
+ case 'connect':
159
+ await this._request('tts.start', { vendor, options: { ...this._channelVars } });
160
+ return { body: '+OK' };
161
+ case 'send':
162
+ await this._request('tts.send', { text });
163
+ return { body: '+OK' };
164
+ case 'flush':
165
+ await this._request('tts.flush', {});
166
+ return { body: '+OK' };
167
+ case 'clear':
168
+ await this._request('tts.clear', {});
169
+ return { body: '+OK' };
170
+ case 'stop':
171
+ case 'close':
172
+ await this._request('tts.stop', {});
173
+ return { body: '+OK' };
174
+ default:
175
+ return { body: `-ERR unknown tts_streaming command ${cmd}` };
176
+ }
177
+ } catch (err) {
178
+ return { body: `-ERR ${err.message}` };
179
+ }
180
+ }
181
+ switch (command) {
182
+ case 'uuid_jambonz_licensing':
183
+ // licensing subsystem is not present in mediajam; respond as FS
184
+ // does for an unavailable module so callers degrade gracefully
185
+ return { body: '-ERR licensing not available' };
186
+ case 'uuid_break':
187
+ await this._request('play.stop', {});
188
+ return { body: '+OK' };
189
+ case 'send_dtmf': {
190
+ const spec = arr.find((a) => a !== this.uuid) || '';
191
+ await this._sendDtmf(spec);
192
+ return { body: '+OK' };
193
+ }
194
+ default:
195
+ throw new Error(`mediajam: api command not supported: ${command}`);
196
+ }
197
+ }
198
+
199
+ /** fsmrf execute() passthrough for the FS apps in use. */
200
+ async execute(app, arg) {
201
+ switch (app) {
202
+ case 'send_dtmf':
203
+ await this._sendDtmf(arg);
204
+ return {};
205
+ case 'set_mute': {
206
+ const muted = /true/.test(arg || '');
207
+ await this._request(muted ? 'endpoint.mute' : 'endpoint.unmute', { direction: 'in' });
208
+ this.muted = muted;
209
+ return {};
210
+ }
211
+ case 'start_dtmf':
212
+ // inband DTMF detection: pending server-side support
213
+ this.inbandDtmfEnabled = true;
214
+ return {};
215
+ case 'hangup':
216
+ await this.destroy();
217
+ return {};
218
+ default:
219
+ throw new Error(`mediajam: execute app not supported: ${app}`);
220
+ }
221
+ }
222
+
223
+ async _sendDtmf(spec) {
224
+ const [digits, duration] = String(spec).split('@');
225
+ const data = { digits };
226
+ if (duration) data.durationMs = parseInt(duration, 10);
227
+ await this._request('dtmf.send', data);
228
+ }
229
+
230
+ async set(param, value) {
231
+ const obj = typeof param === 'object' ? param : { [param]: value };
232
+ for (const [k, v] of Object.entries(obj)) {
233
+ if (v === '' || v === null || v === undefined) delete this._channelVars[k];
234
+ else this._channelVars[k] = String(v);
235
+ }
236
+ const options = {};
237
+ for (const [k, v] of Object.entries(obj)) {
238
+ switch (k) {
239
+ case 'media_timeout':
240
+ options.mediaTimeoutMs = parseInt(v, 10);
241
+ break;
242
+ case 'media_hold_timeout':
243
+ options.holdTimeoutMs = parseInt(v, 10);
244
+ break;
245
+ default:
246
+ // many FS channel variables have no mediajam equivalent; ignore
247
+ break;
248
+ }
249
+ }
250
+ if (Object.keys(options).length > 0) await this._request('endpoint.set', options);
251
+ return this;
252
+ }
253
+
254
+ async export(param, value) {
255
+ return this.set(param, value);
256
+ }
257
+
258
+ async modify(sdp) {
259
+ const { localSdp } = await this._request('endpoint.modify', { remoteSdp: sdp });
260
+ this.remote = parseSdp(sdp);
261
+ if (localSdp) this.local = parseSdp(localSdp);
262
+ return this;
263
+ }
264
+
265
+ async bridge(other) {
266
+ const otherEndpointId = typeof other === 'string' ? other : other.uuid;
267
+ await this._request('bridge.create', { otherEndpointId });
268
+ return this;
269
+ }
270
+
271
+ async unbridge() {
272
+ await this._request('bridge.destroy', {});
273
+ return this;
274
+ }
275
+
276
+ async mute() {
277
+ await this._request('endpoint.mute', { direction: 'in' });
278
+ this.muted = true;
279
+ return this;
280
+ }
281
+
282
+ async unmute() {
283
+ await this._request('endpoint.unmute', { direction: 'in' });
284
+ this.muted = false;
285
+ return this;
286
+ }
287
+
288
+ async toggleMute() {
289
+ return this.muted ? this.unmute() : this.mute();
290
+ }
291
+
292
+ async getChannelVariables() {
293
+ return this._request('endpoint.info', {});
294
+ }
295
+
296
+ async destroy() {
297
+ if (!this.connected) return;
298
+ this.connected = false;
299
+ // fsmrf semantics: an app-initiated destroy() does not emit 'destroy' --
300
+ // the event is reserved for unexpected teardown (media timeout, server
301
+ // shutdown). The feature-server's media-timeout handlers depend on this.
302
+ this._selfDestroyed = true;
303
+ await this._request('endpoint.destroy', {}).catch(() => {});
304
+ }
305
+
306
+ /* fsmrf methods pending mediajam Phase 2 (vendor adapter framework).
307
+ * Present so callers fail with a descriptive rejection rather than a
308
+ * TypeError; each names the protocol family that will back it. */
309
+ /** Begin transcription; mirrors fsmrf's signature. Vendor tuning rides
310
+ * in previously-set channel vars (DEEPGRAM_*, GOOGLE_*, ...), which are
311
+ * forwarded as stt.start options. */
312
+ async startTranscription(opts = {}) {
313
+ let vendor = opts.vendor;
314
+ if (vendor === 'microsoft') vendor = 'azure';
315
+ if (vendor === 'polly') vendor = 'aws';
316
+ const bugname = opts.bugname ||
317
+ (opts.vendor?.startsWith('custom:') ? `${opts.vendor}_transcribe` : `${vendor}_transcribe`);
318
+ await this._request('stt.start', {
319
+ vendor,
320
+ language: opts.locale || 'en-US',
321
+ interim: opts.interim === true,
322
+ channels: opts.channels === 2 ? 2 : 1,
323
+ bugname,
324
+ options: {...this._channelVars}
325
+ });
326
+ }
327
+
328
+ async stopTranscription(opts = {}) {
329
+ let vendor = opts.vendor;
330
+ if (vendor === 'microsoft') vendor = 'azure';
331
+ if (vendor === 'polly') vendor = 'aws';
332
+ const data = {};
333
+ if (opts.bugname) data.bugname = opts.bugname;
334
+ else if (vendor) data.vendor = vendor;
335
+ await this._request('stt.stop', data).catch((err) => {
336
+ // stopping an already-ended session is not an error worth surfacing
337
+ if (err.code !== 'conflict') throw err;
338
+ });
339
+ }
340
+ startTranscriptionTimers() {
341
+ return Promise.reject(new Error('mediajam: startTranscriptionTimers pending Phase 2 (stt.*)'));
342
+ }
343
+ async forkAudioStart(opts = {}) {
344
+ const sampleRate = typeof opts.sampling === 'string'
345
+ ? parseInt(opts.sampling, 10) * (opts.sampling.endsWith('k') ? 1000 : 1)
346
+ : opts.sampling;
347
+ const data = {
348
+ wsUrl: opts.wsUrl,
349
+ mixType: opts.mixType,
350
+ bugname: opts.bugname
351
+ };
352
+ if (sampleRate) data.sampleRate = sampleRate;
353
+ if (opts.metadata) data.metadata = opts.metadata;
354
+ if (opts.bidirectionalAudio) {
355
+ data.bidirectionalAudio = {
356
+ enabled: !!opts.bidirectionalAudio.enabled,
357
+ streaming: !!opts.bidirectionalAudio.streaming,
358
+ sampleRate: opts.bidirectionalAudio.sampleRate || 0
359
+ };
360
+ }
361
+ await this._request('fork.start', data);
362
+ return this;
363
+ }
364
+
365
+ async forkAudioStop(bugname, metadata) {
366
+ const data = {};
367
+ if (bugname) data.bugname = bugname;
368
+ if (metadata) data.metadata = metadata;
369
+ await this._request('fork.stop', data);
370
+ return this;
371
+ }
372
+
373
+ async forkAudioPause(bugname) {
374
+ await this._request('fork.pause', bugname ? {bugname} : {});
375
+ return this;
376
+ }
377
+
378
+ async forkAudioResume(bugname) {
379
+ await this._request('fork.resume', bugname ? {bugname} : {});
380
+ return this;
381
+ }
382
+
383
+ async forkAudioSendText(bugname, metadata) {
384
+ await this._request('fork.sendText', {bugname, metadata});
385
+ return this;
386
+ }
387
+ /* fsmrf-compatible VAD detection. Note on option mapping: fsmrf passed
388
+ * its options to mod_vad_silero POSITIONALLY and in the wrong order
389
+ * (module reads silence-ms, speech-pad-ms, min-speech-ms; fsmrf sent
390
+ * silenceMs, voiceMs, speechPadMs) — here voiceMs maps to minSpeechMs
391
+ * by MEANING, matching the option names' documented intent rather than
392
+ * the FS accident. */
393
+ async startVadDetection(opts) {
394
+ opts = opts || {};
395
+ /* mediajam has one VAD implementation (silero); requests for the FS
396
+ * 'native' energy vad get silero too — its mode knob has no analog
397
+ * and is ignored. Callers listen via media-events' vadDetectionEvent
398
+ * selector, so both vendors' events arrive the same way. */
399
+ await this._request('vad.start', {
400
+ vendor: 'silero',
401
+ strategy: opts.strategy || 'continuous',
402
+ ...(typeof opts.threshold === 'number' && {threshold: opts.threshold}),
403
+ ...(typeof opts.silenceMs === 'number' && {silenceMs: opts.silenceMs}),
404
+ ...(typeof opts.voiceMs === 'number' && {minSpeechMs: opts.voiceMs}),
405
+ ...(typeof opts.speechPadMs === 'number' && {speechPadMs: opts.speechPadMs}),
406
+ ...(opts.bugname && {bugname: opts.bugname})
407
+ });
408
+ return this;
409
+ }
410
+ async stopVadDetection(opts) {
411
+ opts = opts || {};
412
+ await this._request('vad.stop', {
413
+ vendor: 'silero',
414
+ ...(opts.bugname && {bugname: opts.bugname})
415
+ });
416
+ return this;
417
+ }
418
+ dub() {
419
+ return Promise.reject(new Error('mediajam: dub pending Phase 2 (dub.*)'));
420
+ }
421
+ setGain() {
422
+ return Promise.reject(new Error('mediajam: setGain pending Phase 2 (dub.*)'));
423
+ }
424
+ join() {
425
+ return Promise.reject(new Error('mediajam: conference join pending Phase 3 (room.*)'));
426
+ }
427
+
428
+ addCustomEventListener(event, handler) {
429
+ let handlers = this._customEventListeners.get(event);
430
+ if (!handlers) {
431
+ handlers = new Set();
432
+ this._customEventListeners.set(event, handlers);
433
+ }
434
+ handlers.add(handler);
435
+ }
436
+
437
+ removeCustomEventListener(event, handler) {
438
+ const handlers = this._customEventListeners.get(event);
439
+ if (!handlers) return;
440
+ if (handler) handlers.delete(handler);
441
+ else handlers.clear();
442
+ if (handlers.size === 0) this._customEventListeners.delete(event);
443
+ }
444
+
445
+ /** Routes a protocol event frame to fsmrf-compatible emissions. */
446
+ _onEvent(evt, data) {
447
+ switch (evt) {
448
+ case 'endpoint.destroyed':
449
+ this.connected = false;
450
+ for (const [, p] of this._pendingPlays) {
451
+ p.resolve({reason: 'destroyed', playbackSeconds: 0, playbackMilliseconds: 0, playbackLastOffsetPos: 0});
452
+ }
453
+ this._pendingPlays.clear();
454
+ this.ms._endpointGone(this.uuid);
455
+ if (!this._selfDestroyed) this.emit('destroy', { reason: data?.reason });
456
+ break;
457
+ case 'dtmf':
458
+ this.emit('dtmf', { dtmf: data.digit, duration: data.durationMs, source: data.source });
459
+ break;
460
+ case 'play.start':
461
+ this.emit('playback-start', { playId: data.playId, ...ttsVars(data.tts) });
462
+ break;
463
+ case 'play.done': {
464
+ const p = this._pendingPlays.get(data.playId);
465
+ if (p) {
466
+ this._pendingPlays.delete(data.playId);
467
+ // FS reports playback_seconds/_ms from samples read (read-ahead
468
+ // included); mediajam carries that in playbackMs. durationMs
469
+ // (audio actually played) is the fallback for older servers.
470
+ const ms = data.playbackMs ?? data.durationMs ?? 0;
471
+ p.resolve({
472
+ reason: data.reason,
473
+ playbackSeconds: Math.floor(ms / 1000),
474
+ playbackMilliseconds: ms,
475
+ playbackLastOffsetPos: data.lastOffsetPos ?? 0
476
+ });
477
+ }
478
+ this.emit('playback-stop', { playId: data.playId, reason: data.reason, ...ttsVars(data.tts) });
479
+ break;
480
+ }
481
+ default: {
482
+ // normalized media-server events (fork.*, stt.*, tts.*, s2s.*) are
483
+ // delivered under their wire names. Handlers get the fsmrf custom-
484
+ // event contract: (parsedBodyOrText, eventObj) where eventObj
485
+ // supports getHeader (media-bugname et al.) for bug filtering.
486
+ let payload = data;
487
+ if (data && typeof data.json === 'string') {
488
+ try {
489
+ payload = JSON.parse(data.json);
490
+ } catch (err) {
491
+ payload = data.json;
492
+ }
493
+ } else if (data && data.body !== undefined) {
494
+ payload = data.body;
495
+ }
496
+ const eventObj = {
497
+ getHeader: (name) => {
498
+ switch (name) {
499
+ case 'media-bugname': return data?.bugname;
500
+ case 'transcription-vendor': return data?.vendor;
501
+ case 'transcription-session-finished': return data?.finished;
502
+ default: return data?.[name];
503
+ }
504
+ }
505
+ };
506
+ const handlers = this._customEventListeners.get(evt);
507
+ if (handlers) for (const h of handlers) h(payload, eventObj);
508
+ this.emit(evt, payload, eventObj);
509
+ }
510
+ }
511
+ }
512
+ }
513
+
514
+
515
+ /**
516
+ * say:-url (TTS) plays carry synthesis metadata on play events; surface it
517
+ * with the legacy variable_tts_* names the feature-server expects on
518
+ * playback-start / playback-stop.
519
+ */
520
+ function ttsVars(tts) {
521
+ if (!tts) return {};
522
+ return {
523
+ ...(tts.playbackId && {variable_tts_playback_id: tts.playbackId}),
524
+ ...(tts.ttfbMs !== undefined && {variable_tts_time_to_first_byte_ms: String(tts.ttfbMs)}),
525
+ ...(tts.cacheFilename && {variable_tts_cache_filename: tts.cacheFilename}),
526
+ ...(tts.error && {variable_tts_error: tts.error}),
527
+ ...(tts.responseCode !== undefined && {variable_tts_response_code: String(tts.responseCode)}),
528
+ };
529
+ }
530
+
531
+ module.exports = Endpoint;
@@ -0,0 +1,131 @@
1
+ const { EventEmitter } = require('events');
2
+ const Endpoint = require('./endpoint');
3
+
4
+ /**
5
+ * MediaServer represents one connection to a mediajam server, exposing the
6
+ * drachtio-fsmrf MediaServer surface the feature-server uses.
7
+ *
8
+ * Events: 'connect', 'ready', 'error', 'channel::open', 'channel::close'.
9
+ * For fsmrf compatibility, `ms.conn` is an EventEmitter that emits
10
+ * 'esl::ready' and 'esl::end'.
11
+ */
12
+ class MediaServer extends EventEmitter {
13
+ constructor(connection, logger, opts) {
14
+ super();
15
+ this._connection = connection;
16
+ this.logger = logger;
17
+ this.address = opts.address;
18
+ this.port = opts.port;
19
+
20
+ this.connected = false;
21
+ this.maxSessions = 0;
22
+ this.currentSessions = 0;
23
+ this.cps = 0;
24
+ this.cpuIdle = 100;
25
+
26
+ this._endpoints = new Map();
27
+
28
+ // fsmrf compatibility: feature-server listens on ms.conn for esl events
29
+ this.conn = new EventEmitter();
30
+
31
+ connection.on('evt', (frame) => this._onEvent(frame));
32
+ connection.on('stats', (data) => this._onStats(data));
33
+ connection.on('close', () => {
34
+ this.connected = false;
35
+ // fsmrf parity: a self-initiated destroy()/disconnect() tears down
36
+ // quietly; esl::end and endpoint destroy events are reserved for an
37
+ // unexpected loss of the media server
38
+ if (this._selfDestroyed) {
39
+ this._endpoints.clear();
40
+ return;
41
+ }
42
+ for (const [, ep] of this._endpoints) {
43
+ ep._onEvent('endpoint.destroyed', { reason: 'connectionLost' });
44
+ }
45
+ this._endpoints.clear();
46
+ this.conn.emit('esl::end');
47
+ this.emit('disconnect');
48
+ });
49
+ connection.on('error', (err) => this.emit('error', err));
50
+ }
51
+
52
+ _onHello(helloData) {
53
+ this.connected = true;
54
+ this.maxSessions = helloData.maxSessions || 0;
55
+ this.serverVersion = helloData.server;
56
+ this.conn.emit('esl::ready');
57
+ this.emit('connect');
58
+ this.emit('ready');
59
+ }
60
+
61
+ _onStats(data) {
62
+ this.currentSessions = data.sessions ?? this.currentSessions;
63
+ this.maxSessions = data.maxSessions ?? this.maxSessions;
64
+ this.cps = data.cps ?? this.cps;
65
+ this.cpuIdle = data.cpuIdle ?? this.cpuIdle;
66
+ }
67
+
68
+ _onEvent(frame) {
69
+ const ep = this._endpoints.get(frame.ep);
70
+ if (ep) ep._onEvent(frame.evt, frame.data || {});
71
+ }
72
+
73
+ _endpointGone(endpointId) {
74
+ this._endpoints.delete(endpointId);
75
+ this.emit('channel::close', { endpointId });
76
+ }
77
+
78
+ /**
79
+ * Create an endpoint. Options follow fsmrf: {remoteSdp, codecs, ...} plus
80
+ * jambonz drachtioFsmrfOptions (media_timeout, media_hold_timeout).
81
+ */
82
+ async createEndpoint(opts = {}) {
83
+ const data = {};
84
+ if (opts.remoteSdp) data.remoteSdp = opts.remoteSdp;
85
+ if (opts.codecs) data.codecs = Array.isArray(opts.codecs) ? opts.codecs : [opts.codecs];
86
+ if (opts.tags) data.tags = opts.tags;
87
+ const options = {};
88
+ if (opts.media_timeout) options.mediaTimeoutMs = parseInt(opts.media_timeout, 10);
89
+ if (opts.media_hold_timeout) options.holdTimeoutMs = parseInt(opts.media_hold_timeout, 10);
90
+ if (Object.keys(options).length > 0) data.options = options;
91
+
92
+ const res = await this._connection.request('endpoint.create', null, data);
93
+ const ep = new Endpoint(this, res.endpointId, res.localSdp, opts.remoteSdp);
94
+ this._endpoints.set(ep.uuid, ep);
95
+ this.emit('channel::open', { endpointId: ep.uuid });
96
+ return ep;
97
+ }
98
+
99
+ /**
100
+ * fsmrf connectCaller: allocate an endpoint for an inbound call and answer
101
+ * it. Works with drachtio req/res objects (duck-typed).
102
+ */
103
+ async connectCaller(req, res, opts = {}) {
104
+ const endpoint = await this.createEndpoint({ ...opts, remoteSdp: req.body });
105
+ const dialog = await res.send(200, { body: endpoint.local.sdp });
106
+ return { endpoint, dialog };
107
+ }
108
+
109
+ /** Active endpoint count as seen by this client. */
110
+ get endpointCount() {
111
+ return this._endpoints.size;
112
+ }
113
+
114
+ /** Change (or query, with no arg) the server's log level at runtime. */
115
+ async setLogLevel(level) {
116
+ const data = level ? { level } : {};
117
+ const res = await this._connection.request('system.logLevel', null, data);
118
+ return res.level;
119
+ }
120
+
121
+ destroy() {
122
+ this._selfDestroyed = true;
123
+ this._connection.close();
124
+ }
125
+
126
+ disconnect() {
127
+ this.destroy();
128
+ }
129
+ }
130
+
131
+ module.exports = MediaServer;