@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.
- package/.github/workflows/ci.yml +15 -0
- package/.github/workflows/publish.yml +23 -0
- package/eslint.config.js +30 -0
- package/index.js +1 -0
- package/lib/connection.js +128 -0
- package/lib/endpoint.js +531 -0
- package/lib/mediaserver.js +131 -0
- package/lib/mrf.js +55 -0
- package/lib/utils.js +40 -0
- package/package.json +26 -0
- package/test/client.test.js +226 -0
- package/test/integration.test.js +201 -0
- package/test/support/mock-server.js +137 -0
- package/test/support/rtp.js +26 -0
package/lib/mrf.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
const { EventEmitter } = require('events');
|
|
2
|
+
const Connection = require('./connection');
|
|
3
|
+
const MediaServer = require('./mediaserver');
|
|
4
|
+
|
|
5
|
+
const noopLogger = {
|
|
6
|
+
info: () => {},
|
|
7
|
+
error: () => {},
|
|
8
|
+
debug: () => {}
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Mrf is the entry point, mirroring drachtio-fsmrf's Mrf class. The srf
|
|
13
|
+
* argument is accepted for drop-in compatibility but unused — mediajam needs
|
|
14
|
+
* no SIP signaling to allocate endpoints.
|
|
15
|
+
*/
|
|
16
|
+
class Mrf extends EventEmitter {
|
|
17
|
+
constructor(srf, opts = {}) {
|
|
18
|
+
super();
|
|
19
|
+
// allow new Mrf({logger}) without an srf
|
|
20
|
+
if (srf && !opts.logger && (srf.info || srf.logger)) {
|
|
21
|
+
if (srf.info) {
|
|
22
|
+
opts = { logger: srf };
|
|
23
|
+
srf = null;
|
|
24
|
+
} else if (srf.logger) {
|
|
25
|
+
opts = srf;
|
|
26
|
+
srf = null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
this.srf = srf;
|
|
30
|
+
this.logger = opts.logger || noopLogger;
|
|
31
|
+
this.mediaservers = [];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Connect to a mediajam server.
|
|
36
|
+
* @param {object} opts - {address, port} ('secret'/'listenPort' accepted
|
|
37
|
+
* and ignored for fsmrf compatibility)
|
|
38
|
+
*/
|
|
39
|
+
async connect(opts) {
|
|
40
|
+
const { address, port = 9090 } = opts;
|
|
41
|
+
const connection = new Connection(this.logger);
|
|
42
|
+
const ms = new MediaServer(connection, this.logger, { address, port });
|
|
43
|
+
const helloData = await connection.connect({ address, port }, clientInfo());
|
|
44
|
+
ms._onHello(helloData);
|
|
45
|
+
this.mediaservers.push(ms);
|
|
46
|
+
return ms;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function clientInfo() {
|
|
51
|
+
const { name, version } = require('../package.json');
|
|
52
|
+
return `${name}/${version}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
module.exports = Mrf;
|
package/lib/utils.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extract {sdp, mediaIp, mediaPort} the way fsmrf exposes endpoint.local /
|
|
3
|
+
* endpoint.remote.
|
|
4
|
+
*/
|
|
5
|
+
function parseSdp(sdp) {
|
|
6
|
+
const out = { sdp, mediaIp: null, mediaPort: null };
|
|
7
|
+
if (!sdp) return out;
|
|
8
|
+
for (const line of sdp.split(/\r?\n/)) {
|
|
9
|
+
if (line.startsWith('c=IN IP4 ')) out.mediaIp = line.slice(9).trim();
|
|
10
|
+
else if (line.startsWith('m=audio ')) {
|
|
11
|
+
const port = parseInt(line.split(' ')[1], 10);
|
|
12
|
+
if (!Number.isNaN(port)) out.mediaPort = port;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
return out;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Translate FreeSWITCH play url schemes to mediajam schemes.
|
|
20
|
+
* silence_stream://<ms> -> silence://?duration=<ms>
|
|
21
|
+
* tone_stream://... -> tone:// (best-effort)
|
|
22
|
+
* file/http(s) and bare paths pass through
|
|
23
|
+
*/
|
|
24
|
+
function translatePlayUrl(url) {
|
|
25
|
+
const silence = /^silence_stream:\/\/(-?\d+)/.exec(url);
|
|
26
|
+
if (silence) {
|
|
27
|
+
const ms = parseInt(silence[1], 10);
|
|
28
|
+
return ms < 0 ? 'silence://' : `silence://?duration=${ms}`;
|
|
29
|
+
}
|
|
30
|
+
if (url.startsWith('tone_stream://')) {
|
|
31
|
+
// FS tone_stream syntax is rich; map the common single-frequency form
|
|
32
|
+
// %(<on-ms>,<off-ms>,<freq>) and fall back to a 440Hz tone
|
|
33
|
+
const m = /%\(\s*(\d+)\s*,\s*\d+\s*,\s*(\d+)/.exec(url);
|
|
34
|
+
if (m) return `tone://?freq=${m[2]}&duration=${m[1]}`;
|
|
35
|
+
return 'tone://';
|
|
36
|
+
}
|
|
37
|
+
return url;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
module.exports = { parseSdp, translatePlayUrl };
|
package/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@jambonz/mrf",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"main": "index.js",
|
|
5
|
+
"scripts": {
|
|
6
|
+
"test": "node --test",
|
|
7
|
+
"jslint": "eslint .",
|
|
8
|
+
"jslint:fix": "eslint . --fix"
|
|
9
|
+
},
|
|
10
|
+
"engines": {
|
|
11
|
+
"node": ">=18"
|
|
12
|
+
},
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+https://github.com/jambonz/mrf.git"
|
|
16
|
+
},
|
|
17
|
+
"author": "Dave Horton",
|
|
18
|
+
"license": "MIT",
|
|
19
|
+
"bugs": {
|
|
20
|
+
"url": "https://github.com/jambonz/mrf/issues"
|
|
21
|
+
},
|
|
22
|
+
"homepage": "https://github.com/jambonz/mrf#readme",
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"eslint": "^9.0.0"
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
const { test } = require('node:test');
|
|
2
|
+
const assert = require('node:assert');
|
|
3
|
+
const Mrf = require('..');
|
|
4
|
+
const MockMediajam = require('./support/mock-server');
|
|
5
|
+
|
|
6
|
+
async function setup(t) {
|
|
7
|
+
const mock = new MockMediajam();
|
|
8
|
+
const port = await mock.listen();
|
|
9
|
+
const mrf = new Mrf();
|
|
10
|
+
const ms = await mrf.connect({ address: '127.0.0.1', port });
|
|
11
|
+
t.after(() => {
|
|
12
|
+
ms.destroy();
|
|
13
|
+
mock.close();
|
|
14
|
+
});
|
|
15
|
+
return { mock, ms };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
test('connects and exposes server hello', async(t) => {
|
|
19
|
+
const { ms } = await setup(t);
|
|
20
|
+
assert.equal(ms.connected, true);
|
|
21
|
+
assert.equal(ms.maxSessions, 100);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('createEndpoint returns fsmrf-shaped endpoint', async(t) => {
|
|
25
|
+
const { ms, mock } = await setup(t);
|
|
26
|
+
const ep = await ms.createEndpoint({ remoteSdp: 'v=0\r\nc=IN IP4 1.2.3.4\r\nm=audio 5004 RTP/AVP 0\r\n' });
|
|
27
|
+
assert.ok(ep.uuid);
|
|
28
|
+
assert.equal(ep.connected, true);
|
|
29
|
+
assert.equal(ep.local.mediaIp, '127.0.0.1');
|
|
30
|
+
assert.equal(ep.local.mediaPort, 41000);
|
|
31
|
+
assert.equal(ep.remote.mediaIp, '1.2.3.4');
|
|
32
|
+
assert.equal(ep.remote.mediaPort, 5004);
|
|
33
|
+
const createReq = mock.requests.find((r) => r.cmd === 'endpoint.create');
|
|
34
|
+
assert.ok(createReq.data.remoteSdp);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('media_timeout option maps to mediaTimeoutMs', async(t) => {
|
|
38
|
+
const { ms, mock } = await setup(t);
|
|
39
|
+
await ms.createEndpoint({ media_timeout: '30000' });
|
|
40
|
+
const createReq = mock.requests.find((r) => r.cmd === 'endpoint.create');
|
|
41
|
+
assert.equal(createReq.data.options.mediaTimeoutMs, 30000);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('play resolves on play.done with fsmrf-style result', async(t) => {
|
|
45
|
+
const { ms } = await setup(t);
|
|
46
|
+
const ep = await ms.createEndpoint({});
|
|
47
|
+
const events = [];
|
|
48
|
+
ep.on('playback-start', () => events.push('start'));
|
|
49
|
+
ep.on('playback-stop', () => events.push('stop'));
|
|
50
|
+
const result = await ep.play('silence_stream://1000');
|
|
51
|
+
assert.equal(result.reason, 'completed');
|
|
52
|
+
assert.equal(result.playbackSeconds, 0);
|
|
53
|
+
assert.equal(result.playbackMilliseconds, 100);
|
|
54
|
+
assert.equal(result.playbackLastOffsetPos, 800);
|
|
55
|
+
assert.deepEqual(events, ['start', 'stop']);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('play PlaybackOptions form passes seekOffset', async(t) => {
|
|
59
|
+
const { ms, mock } = await setup(t);
|
|
60
|
+
const ep = await ms.createEndpoint({});
|
|
61
|
+
await ep.play({ file: '/tmp/foo.wav', seekOffset: 8000 });
|
|
62
|
+
const playReq = mock.requests.find((r) => r.cmd === 'play.start');
|
|
63
|
+
assert.deepEqual(playReq.data.urls, ['/tmp/foo.wav']);
|
|
64
|
+
assert.equal(playReq.data.seekOffset, 8000);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('play url translation', async(t) => {
|
|
68
|
+
const { ms, mock } = await setup(t);
|
|
69
|
+
const ep = await ms.createEndpoint({});
|
|
70
|
+
await ep.play(['silence_stream://500', 'tone_stream://L=1;%(250, 0, 440)', 'https://x.test/a.wav']);
|
|
71
|
+
const playReq = mock.requests.find((r) => r.cmd === 'play.start');
|
|
72
|
+
assert.deepEqual(playReq.data.urls, [
|
|
73
|
+
'silence://?duration=500',
|
|
74
|
+
'tone://?freq=440&duration=250',
|
|
75
|
+
'https://x.test/a.wav'
|
|
76
|
+
]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('api uuid_break maps to play.stop', async(t) => {
|
|
80
|
+
const { ms, mock } = await setup(t);
|
|
81
|
+
const ep = await ms.createEndpoint({});
|
|
82
|
+
await ep.api('uuid_break', ep.uuid);
|
|
83
|
+
assert.ok(mock.requests.find((r) => r.cmd === 'play.stop' && r.ep === ep.uuid));
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('execute send_dtmf maps digits and duration', async(t) => {
|
|
87
|
+
const { ms, mock } = await setup(t);
|
|
88
|
+
const ep = await ms.createEndpoint({});
|
|
89
|
+
await ep.execute('send_dtmf', '1234#@150');
|
|
90
|
+
const req = mock.requests.find((r) => r.cmd === 'dtmf.send');
|
|
91
|
+
assert.equal(req.data.digits, '1234#');
|
|
92
|
+
assert.equal(req.data.durationMs, 150);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('bridge and unbridge', async(t) => {
|
|
96
|
+
const { ms, mock } = await setup(t);
|
|
97
|
+
const a = await ms.createEndpoint({});
|
|
98
|
+
const b = await ms.createEndpoint({});
|
|
99
|
+
await a.bridge(b);
|
|
100
|
+
const req = mock.requests.find((r) => r.cmd === 'bridge.create');
|
|
101
|
+
assert.equal(req.ep, a.uuid);
|
|
102
|
+
assert.equal(req.data.otherEndpointId, b.uuid);
|
|
103
|
+
await a.unbridge();
|
|
104
|
+
assert.ok(mock.requests.find((r) => r.cmd === 'bridge.destroy'));
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('self-initiated destroy does NOT emit destroy (fsmrf parity)', async(t) => {
|
|
108
|
+
const { ms } = await setup(t);
|
|
109
|
+
const ep = await ms.createEndpoint({});
|
|
110
|
+
let emitted = false;
|
|
111
|
+
ep.once('destroy', () => emitted = true);
|
|
112
|
+
await ep.destroy();
|
|
113
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
114
|
+
assert.equal(emitted, false, 'destroy event must not fire for app-initiated destroy');
|
|
115
|
+
assert.equal(ep.connected, false);
|
|
116
|
+
assert.equal(ms.endpointCount, 0);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('server-initiated destruction emits destroy', async(t) => {
|
|
120
|
+
const { ms, mock } = await setup(t);
|
|
121
|
+
const ep = await ms.createEndpoint({});
|
|
122
|
+
const destroyed = new Promise((resolve) => ep.once('destroy', resolve));
|
|
123
|
+
mock.pushEvent(ep.uuid, 'endpoint.destroyed', { reason: 'mediaTimeout' });
|
|
124
|
+
const evt = await destroyed;
|
|
125
|
+
assert.equal(evt.reason, 'mediaTimeout');
|
|
126
|
+
assert.equal(ep.connected, false);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('dtmf events surface in fsmrf shape', async(t) => {
|
|
130
|
+
const { ms, mock } = await setup(t);
|
|
131
|
+
const ep = await ms.createEndpoint({});
|
|
132
|
+
const dtmf = new Promise((resolve) => ep.once('dtmf', resolve));
|
|
133
|
+
mock.pushEvent(ep.uuid, 'dtmf', { digit: '5', durationMs: 120, source: 'rfc2833' });
|
|
134
|
+
const evt = await dtmf;
|
|
135
|
+
assert.deepEqual(evt, { dtmf: '5', duration: 120, source: 'rfc2833' });
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('custom event listeners receive normalized events with fsmrf contract', async(t) => {
|
|
139
|
+
const { ms, mock } = await setup(t);
|
|
140
|
+
const ep = await ms.createEndpoint({});
|
|
141
|
+
const got = new Promise((resolve) => ep.addCustomEventListener('stt.transcription',
|
|
142
|
+
(payload, evtObj) => resolve({payload, evtObj})));
|
|
143
|
+
mock.pushEvent(ep.uuid, 'stt.transcription',
|
|
144
|
+
{ vendor: 'deepgram', bugname: 'default', json: '{"text":"hi"}' });
|
|
145
|
+
const {payload, evtObj} = await got;
|
|
146
|
+
assert.equal(payload.text, 'hi', 'json payload is parsed');
|
|
147
|
+
assert.equal(evtObj.getHeader('media-bugname'), 'default');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('fork.play_audio delivers parsed file payload', async(t) => {
|
|
151
|
+
const { ms, mock } = await setup(t);
|
|
152
|
+
const ep = await ms.createEndpoint({});
|
|
153
|
+
const got = new Promise((resolve) => ep.addCustomEventListener('fork.play_audio',
|
|
154
|
+
(payload, evtObj) => resolve({payload, evtObj})));
|
|
155
|
+
mock.pushEvent(ep.uuid, 'fork.play_audio',
|
|
156
|
+
{ bugname: 'b1', json: '{"file":"/tmp/x.tmp.r16","audioContentType":"raw"}' });
|
|
157
|
+
const {payload, evtObj} = await got;
|
|
158
|
+
assert.equal(payload.file, '/tmp/x.tmp.r16');
|
|
159
|
+
assert.equal(evtObj.getHeader('media-bugname'), 'b1');
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('stats update mediaserver gauges', async(t) => {
|
|
163
|
+
const { ms, mock } = await setup(t);
|
|
164
|
+
const updated = new Promise((resolve) => {
|
|
165
|
+
const iv = setInterval(() => {
|
|
166
|
+
if (ms.currentSessions === 42) {
|
|
167
|
+
clearInterval(iv);
|
|
168
|
+
resolve();
|
|
169
|
+
}
|
|
170
|
+
}, 5);
|
|
171
|
+
});
|
|
172
|
+
mock.pushStats({ sessions: 42, maxSessions: 100, cpuIdle: 88 });
|
|
173
|
+
await updated;
|
|
174
|
+
assert.equal(ms.cpuIdle, 88);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test('connection loss destroys endpoints with connectionLost', async() => {
|
|
178
|
+
const mock = new MockMediajam();
|
|
179
|
+
const port = await mock.listen();
|
|
180
|
+
const mrf = new Mrf();
|
|
181
|
+
const ms = await mrf.connect({ address: '127.0.0.1', port });
|
|
182
|
+
const ep = await ms.createEndpoint({});
|
|
183
|
+
const destroyed = new Promise((resolve) => ep.once('destroy', resolve));
|
|
184
|
+
const ended = new Promise((resolve) => ms.conn.once('esl::end', resolve));
|
|
185
|
+
mock.close();
|
|
186
|
+
for (const socket of mock.sockets) socket.destroy();
|
|
187
|
+
const evt = await destroyed;
|
|
188
|
+
await ended;
|
|
189
|
+
assert.equal(evt.reason, 'connectionLost');
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test('setLogLevel changes and queries server log level', async(t) => {
|
|
193
|
+
const { ms } = await setup(t);
|
|
194
|
+
assert.equal(await ms.setLogLevel('debug'), 'debug');
|
|
195
|
+
assert.equal(await ms.setLogLevel(), 'debug');
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test('startTranscription forwards channel vars as options', async(t) => {
|
|
199
|
+
const { ms, mock } = await setup(t);
|
|
200
|
+
const ep = await ms.createEndpoint({});
|
|
201
|
+
await ep.set({DEEPGRAM_API_KEY: 'k123', DEEPGRAM_SPEECH_MODEL: 'nova-2', UNRELATED: ''});
|
|
202
|
+
await ep.startTranscription({vendor: 'deepgram', locale: 'en-US', interim: true});
|
|
203
|
+
const req = mock.requests.find((r) => r.cmd === 'stt.start');
|
|
204
|
+
assert.equal(req.data.vendor, 'deepgram');
|
|
205
|
+
assert.equal(req.data.language, 'en-US');
|
|
206
|
+
assert.equal(req.data.interim, true);
|
|
207
|
+
assert.equal(req.data.bugname, 'deepgram_transcribe');
|
|
208
|
+
assert.equal(req.data.options.DEEPGRAM_API_KEY, 'k123');
|
|
209
|
+
assert.equal(req.data.options.DEEPGRAM_SPEECH_MODEL, 'nova-2');
|
|
210
|
+
assert.ok(!('UNRELATED' in req.data.options), 'empty vars are cleared');
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test('stt events deliver fsmrf header aliases', async(t) => {
|
|
214
|
+
const { ms, mock } = await setup(t);
|
|
215
|
+
const ep = await ms.createEndpoint({});
|
|
216
|
+
const got = new Promise((resolve) => ep.addCustomEventListener('stt.transcription',
|
|
217
|
+
(payload, evtObj) => resolve({payload, evtObj})));
|
|
218
|
+
mock.pushEvent(ep.uuid, 'stt.transcription', {
|
|
219
|
+
vendor: 'deepgram', bugname: 'deepgram_transcribe', finished: 'true',
|
|
220
|
+
json: '{"is_final":true}'
|
|
221
|
+
});
|
|
222
|
+
const {payload, evtObj} = await got;
|
|
223
|
+
assert.equal(payload.is_final, true);
|
|
224
|
+
assert.equal(evtObj.getHeader('transcription-vendor'), 'deepgram');
|
|
225
|
+
assert.equal(evtObj.getHeader('transcription-session-finished'), 'true');
|
|
226
|
+
});
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
// Integration tests against a real mediajam server binary.
|
|
2
|
+
//
|
|
3
|
+
// go build -o /tmp/mediajam ./cmd/mediajam (in the mediajam repo)
|
|
4
|
+
// MEDIAJAM_BIN=/tmp/mediajam npm test
|
|
5
|
+
//
|
|
6
|
+
// Skipped when MEDIAJAM_BIN is unset (e.g. in CI without the binary).
|
|
7
|
+
|
|
8
|
+
const { test } = require('node:test');
|
|
9
|
+
const assert = require('node:assert');
|
|
10
|
+
const { spawn } = require('node:child_process');
|
|
11
|
+
const dgram = require('node:dgram');
|
|
12
|
+
const net = require('node:net');
|
|
13
|
+
const Mrf = require('..');
|
|
14
|
+
const { buildRtp, parseRtp } = require('./support/rtp');
|
|
15
|
+
|
|
16
|
+
const BIN = process.env.MEDIAJAM_BIN;
|
|
17
|
+
const skip = BIN ? false : 'set MEDIAJAM_BIN to run integration tests';
|
|
18
|
+
|
|
19
|
+
const CONTROL_PORT = 19090 + (process.pid % 1000);
|
|
20
|
+
|
|
21
|
+
let serverProc;
|
|
22
|
+
let mrf;
|
|
23
|
+
let ms;
|
|
24
|
+
|
|
25
|
+
async function startServer() {
|
|
26
|
+
serverProc = spawn(BIN, [
|
|
27
|
+
'-addr', `127.0.0.1:${CONTROL_PORT}`,
|
|
28
|
+
'-rtp-ip', '127.0.0.1',
|
|
29
|
+
'-advertise-ip', '127.0.0.1',
|
|
30
|
+
'-rtp-port-min', '46000',
|
|
31
|
+
'-rtp-port-max', '46998',
|
|
32
|
+
'-log-level', 'warn'
|
|
33
|
+
], { stdio: ['ignore', 'inherit', 'inherit'] });
|
|
34
|
+
|
|
35
|
+
// wait for the control port to accept
|
|
36
|
+
for (let i = 0; i < 50; i++) {
|
|
37
|
+
const ok = await new Promise((resolve) => {
|
|
38
|
+
const s = net.connect({ host: '127.0.0.1', port: CONTROL_PORT }, () => {
|
|
39
|
+
s.destroy();
|
|
40
|
+
resolve(true);
|
|
41
|
+
});
|
|
42
|
+
s.on('error', () => resolve(false));
|
|
43
|
+
});
|
|
44
|
+
if (ok) return;
|
|
45
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
46
|
+
}
|
|
47
|
+
throw new Error('mediajam did not start');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function makeCaller() {
|
|
51
|
+
return new Promise((resolve) => {
|
|
52
|
+
const sock = dgram.createSocket('udp4');
|
|
53
|
+
sock.bind(0, '127.0.0.1', () => resolve(sock));
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function sdpFor(sock) {
|
|
58
|
+
const { port } = sock.address();
|
|
59
|
+
return 'v=0\r\no=- 1 1 IN IP4 127.0.0.1\r\ns=t\r\nc=IN IP4 127.0.0.1\r\nt=0 0\r\n' +
|
|
60
|
+
`m=audio ${port} RTP/AVP 0 101\r\na=rtpmap:0 PCMU/8000\r\n` +
|
|
61
|
+
'a=rtpmap:101 telephone-event/8000\r\na=fmtp:101 0-16\r\n';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** stream caller RTP at 20ms cadence until stopped; returns stop fn */
|
|
65
|
+
function pumpRtp(sock, destPort, fillByte) {
|
|
66
|
+
const payload = Buffer.alloc(160, fillByte);
|
|
67
|
+
let seq = 1, ts = 0;
|
|
68
|
+
const iv = setInterval(() => {
|
|
69
|
+
sock.send(buildRtp({ pt: 0, seq: seq++, ts: ts += 160, ssrc: 0x1234, payload }), destPort, '127.0.0.1');
|
|
70
|
+
}, 20);
|
|
71
|
+
return () => clearInterval(iv);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** collect RTP packets arriving at a caller socket until pred matches */
|
|
75
|
+
function waitForPacket(sock, pred, timeoutMs) {
|
|
76
|
+
return new Promise((resolve, reject) => {
|
|
77
|
+
const timer = setTimeout(() => {
|
|
78
|
+
sock.removeListener('message', onMsg);
|
|
79
|
+
reject(new Error('timed out waiting for RTP'));
|
|
80
|
+
}, timeoutMs);
|
|
81
|
+
const onMsg = (msg) => {
|
|
82
|
+
const pkt = parseRtp(msg);
|
|
83
|
+
if (pkt && pred(pkt)) {
|
|
84
|
+
clearTimeout(timer);
|
|
85
|
+
sock.removeListener('message', onMsg);
|
|
86
|
+
resolve(pkt);
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
sock.on('message', onMsg);
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
test('integration: connect', { skip }, async() => {
|
|
94
|
+
await startServer();
|
|
95
|
+
mrf = new Mrf();
|
|
96
|
+
ms = await mrf.connect({ address: '127.0.0.1', port: CONTROL_PORT });
|
|
97
|
+
assert.equal(ms.connected, true);
|
|
98
|
+
assert.ok(ms.serverVersion.startsWith('mediajam/'));
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('integration: createEndpoint answer mode', { skip }, async(t) => {
|
|
102
|
+
const caller = await makeCaller();
|
|
103
|
+
t.after(() => caller.close());
|
|
104
|
+
const ep = await ms.createEndpoint({ remoteSdp: sdpFor(caller) });
|
|
105
|
+
assert.equal(ep.local.mediaIp, '127.0.0.1');
|
|
106
|
+
assert.ok(ep.local.mediaPort >= 46000 && ep.local.mediaPort <= 46998);
|
|
107
|
+
// server ticks silence toward the caller immediately
|
|
108
|
+
await waitForPacket(caller, (p) => p.pt === 0 && p.payload.length === 160, 2000);
|
|
109
|
+
await ep.destroy();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('integration: play tone resolves with duration and renders audio', { skip }, async(t) => {
|
|
113
|
+
const caller = await makeCaller();
|
|
114
|
+
t.after(() => caller.close());
|
|
115
|
+
const ep = await ms.createEndpoint({ remoteSdp: sdpFor(caller) });
|
|
116
|
+
t.after(() => ep.destroy());
|
|
117
|
+
|
|
118
|
+
const nonSilence = waitForPacket(caller,
|
|
119
|
+
(p) => p.pt === 0 && p.payload.some((b) => b !== 0xff && b !== 0x7f), 2000);
|
|
120
|
+
const result = await ep.play('tone://?freq=800&duration=200');
|
|
121
|
+
assert.equal(result.reason, 'completed');
|
|
122
|
+
assert.ok(result.playbackMilliseconds >= 160 && result.playbackMilliseconds <= 260,
|
|
123
|
+
`playbackMilliseconds = ${result.playbackMilliseconds}`);
|
|
124
|
+
await nonSilence;
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('integration: uuid_break stops playback', { skip }, async(t) => {
|
|
128
|
+
const caller = await makeCaller();
|
|
129
|
+
t.after(() => caller.close());
|
|
130
|
+
const ep = await ms.createEndpoint({ remoteSdp: sdpFor(caller) });
|
|
131
|
+
t.after(() => ep.destroy());
|
|
132
|
+
|
|
133
|
+
const playDone = ep.play('silence://?duration=10000');
|
|
134
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
135
|
+
await ep.api('uuid_break', ep.uuid);
|
|
136
|
+
const result = await playDone;
|
|
137
|
+
assert.equal(result.reason, 'stopped');
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('integration: bridge carries audio between endpoints', { skip }, async(t) => {
|
|
141
|
+
const callerA = await makeCaller();
|
|
142
|
+
const callerB = await makeCaller();
|
|
143
|
+
t.after(() => {
|
|
144
|
+
callerA.close();
|
|
145
|
+
callerB.close();
|
|
146
|
+
});
|
|
147
|
+
const epA = await ms.createEndpoint({ remoteSdp: sdpFor(callerA) });
|
|
148
|
+
const epB = await ms.createEndpoint({ remoteSdp: sdpFor(callerB) });
|
|
149
|
+
t.after(async() => {
|
|
150
|
+
await epA.destroy();
|
|
151
|
+
await epB.destroy();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
await epA.bridge(epB);
|
|
155
|
+
const stopPump = pumpRtp(callerA, epA.local.mediaPort, 0x12);
|
|
156
|
+
t.after(stopPump);
|
|
157
|
+
|
|
158
|
+
// caller B hears caller A's pattern
|
|
159
|
+
await waitForPacket(callerB,
|
|
160
|
+
(p) => p.pt === 0 && p.payload[0] === 0x12 && p.payload[80] === 0x12, 3000);
|
|
161
|
+
|
|
162
|
+
// unbridge reverts B to silence
|
|
163
|
+
await epA.unbridge();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test('integration: outbound DTMF arrives as RFC2833', { skip }, async(t) => {
|
|
167
|
+
const caller = await makeCaller();
|
|
168
|
+
t.after(() => caller.close());
|
|
169
|
+
const ep = await ms.createEndpoint({ remoteSdp: sdpFor(caller) });
|
|
170
|
+
t.after(() => ep.destroy());
|
|
171
|
+
|
|
172
|
+
const dtmfPkt = waitForPacket(caller, (p) => p.pt === 101, 3000);
|
|
173
|
+
await ep.execute('send_dtmf', '7@120');
|
|
174
|
+
const pkt = await dtmfPkt;
|
|
175
|
+
assert.equal(pkt.payload[0], 7); // event code for digit 7
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test('integration: inbound RFC2833 emits dtmf event', { skip }, async(t) => {
|
|
179
|
+
const caller = await makeCaller();
|
|
180
|
+
t.after(() => caller.close());
|
|
181
|
+
const ep = await ms.createEndpoint({ remoteSdp: sdpFor(caller) });
|
|
182
|
+
t.after(() => ep.destroy());
|
|
183
|
+
|
|
184
|
+
const dtmf = new Promise((resolve) => ep.once('dtmf', resolve));
|
|
185
|
+
// event 5, end bit set, duration 960 samples (120ms @8k)
|
|
186
|
+
const payload = Buffer.from([5, 0x8a, 0x03, 0xc0]);
|
|
187
|
+
for (let i = 0; i < 3; i++) {
|
|
188
|
+
caller.send(buildRtp({ pt: 101, seq: 100 + i, ts: 5000, ssrc: 0x99, payload }),
|
|
189
|
+
ep.local.mediaPort, '127.0.0.1');
|
|
190
|
+
}
|
|
191
|
+
const evt = await dtmf;
|
|
192
|
+
assert.equal(evt.dtmf, '5');
|
|
193
|
+
assert.equal(evt.duration, 120);
|
|
194
|
+
assert.equal(evt.source, 'rfc2833');
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test('integration: teardown', { skip }, async() => {
|
|
198
|
+
ms.destroy();
|
|
199
|
+
serverProc.kill('SIGTERM');
|
|
200
|
+
await new Promise((r) => serverProc.once('exit', r));
|
|
201
|
+
});
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
const net = require('net');
|
|
2
|
+
const { randomUUID } = require('crypto');
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A minimal in-process mediajam control-protocol server for testing the
|
|
6
|
+
* client without the real Go binary. Implements hello, endpoint lifecycle,
|
|
7
|
+
* play (with timed play.done), dtmf.send, bridge, mute.
|
|
8
|
+
*/
|
|
9
|
+
class MockMediajam {
|
|
10
|
+
constructor() {
|
|
11
|
+
this.server = null;
|
|
12
|
+
this.port = 0;
|
|
13
|
+
this.endpoints = new Map(); // id -> {socket, playTimers}
|
|
14
|
+
this.requests = []; // every req frame received, for assertions
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
listen() {
|
|
18
|
+
return new Promise((resolve) => {
|
|
19
|
+
this.server = net.createServer((socket) => this._onConnection(socket));
|
|
20
|
+
this.server.listen(0, '127.0.0.1', () => {
|
|
21
|
+
this.port = this.server.address().port;
|
|
22
|
+
resolve(this.port);
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
close() {
|
|
28
|
+
for (const [, ep] of this.endpoints) {
|
|
29
|
+
for (const t of ep.playTimers) clearTimeout(t);
|
|
30
|
+
}
|
|
31
|
+
this.server.close();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
send(socket, obj) {
|
|
35
|
+
socket.write(`${JSON.stringify(obj)}\n`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// push an arbitrary event to the connection owning an endpoint
|
|
39
|
+
pushEvent(epId, evt, data) {
|
|
40
|
+
const ep = this.endpoints.get(epId);
|
|
41
|
+
if (ep) this.send(ep.socket, { t: 'evt', ep: epId, evt, data });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
pushStats(data) {
|
|
45
|
+
for (const socket of this.sockets || []) {
|
|
46
|
+
this.send(socket, { t: 'stats', data });
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
_onConnection(socket) {
|
|
51
|
+
(this.sockets = this.sockets || new Set()).add(socket);
|
|
52
|
+
socket.on('close', () => this.sockets.delete(socket));
|
|
53
|
+
let buf = '';
|
|
54
|
+
socket.on('data', (chunk) => {
|
|
55
|
+
buf += chunk.toString();
|
|
56
|
+
let idx;
|
|
57
|
+
while ((idx = buf.indexOf('\n')) >= 0) {
|
|
58
|
+
const line = buf.slice(0, idx);
|
|
59
|
+
buf = buf.slice(idx + 1);
|
|
60
|
+
if (line.trim()) this._onFrame(socket, JSON.parse(line));
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
_onFrame(socket, frame) {
|
|
66
|
+
if (frame.t === 'hello') {
|
|
67
|
+
this.send(socket, { t: 'hello', data: { version: 1, server: 'mock/0.0.0', maxSessions: 100 } });
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
if (frame.t !== 'req') return;
|
|
71
|
+
this.requests.push(frame);
|
|
72
|
+
const res = (data) => this.send(socket, { t: 'res', id: frame.id, ok: true, data });
|
|
73
|
+
const fail = (code, msg) => this.send(socket, { t: 'res', id: frame.id, ok: false, err: { code, msg } });
|
|
74
|
+
|
|
75
|
+
switch (frame.cmd) {
|
|
76
|
+
case 'endpoint.create': {
|
|
77
|
+
const id = randomUUID();
|
|
78
|
+
this.endpoints.set(id, { socket, playTimers: new Set() });
|
|
79
|
+
res({
|
|
80
|
+
endpointId: id,
|
|
81
|
+
localSdp: 'v=0\r\no=mock 1 1 IN IP4 127.0.0.1\r\ns=m\r\nc=IN IP4 127.0.0.1\r\nt=0 0\r\n' +
|
|
82
|
+
'm=audio 41000 RTP/AVP 0 101\r\na=rtpmap:0 PCMU/8000\r\n'
|
|
83
|
+
});
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
case 'endpoint.destroy': {
|
|
87
|
+
const ep = this.endpoints.get(frame.ep);
|
|
88
|
+
if (!ep) return fail('unknown_endpoint', frame.ep);
|
|
89
|
+
this.endpoints.delete(frame.ep);
|
|
90
|
+
res({});
|
|
91
|
+
this.send(socket, { t: 'evt', ep: frame.ep, evt: 'endpoint.destroyed', data: { reason: 'commanded' } });
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
case 'endpoint.modify':
|
|
95
|
+
res({ localSdp: 'v=0\r\nc=IN IP4 127.0.0.1\r\nm=audio 41002 RTP/AVP 0\r\n' });
|
|
96
|
+
break;
|
|
97
|
+
case 'play.start': {
|
|
98
|
+
const ep = this.endpoints.get(frame.ep);
|
|
99
|
+
if (!ep) return fail('unknown_endpoint', frame.ep);
|
|
100
|
+
const playId = randomUUID().slice(0, 8);
|
|
101
|
+
res({ playId });
|
|
102
|
+
this.send(socket, { t: 'evt', ep: frame.ep, evt: 'play.start', data: { playId } });
|
|
103
|
+
const t = setTimeout(() => {
|
|
104
|
+
ep.playTimers.delete(t);
|
|
105
|
+
this.send(socket, {
|
|
106
|
+
t: 'evt', ep: frame.ep, evt: 'play.done',
|
|
107
|
+
data: { playId, reason: 'completed', durationMs: 100, playbackMs: 100, lastOffsetPos: 800 }
|
|
108
|
+
});
|
|
109
|
+
}, 30);
|
|
110
|
+
ep.playTimers.add(t);
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
case 'play.stop':
|
|
114
|
+
case 'dtmf.send':
|
|
115
|
+
case 'stt.start':
|
|
116
|
+
case 'stt.stop':
|
|
117
|
+
case 'endpoint.set':
|
|
118
|
+
case 'endpoint.mute':
|
|
119
|
+
case 'endpoint.unmute':
|
|
120
|
+
case 'bridge.create':
|
|
121
|
+
case 'bridge.destroy':
|
|
122
|
+
res({});
|
|
123
|
+
break;
|
|
124
|
+
case 'endpoint.info':
|
|
125
|
+
res({ codec: 'PCMU', stats: { packetsIn: 0 } });
|
|
126
|
+
break;
|
|
127
|
+
case 'system.logLevel':
|
|
128
|
+
this.logLevel = (frame.data && frame.data.level) || this.logLevel || 'info';
|
|
129
|
+
res({ level: this.logLevel });
|
|
130
|
+
break;
|
|
131
|
+
default:
|
|
132
|
+
fail('unknown_command', frame.cmd);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
module.exports = MockMediajam;
|