@fonoster/amd 0.25.1 → 0.25.2
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/dist/agi/server.d.ts +3 -4
- package/dist/agi/server.js +50 -28
- package/dist/probe/collectPcm.js +3 -1
- package/dist/probe/runProbe.js +3 -1
- package/dist/probe/upsample.d.ts +3 -0
- package/dist/probe/upsample.js +53 -0
- package/package.json +3 -3
package/dist/agi/server.d.ts
CHANGED
|
@@ -8,10 +8,9 @@ import { AgiServer } from "./AgiServer";
|
|
|
8
8
|
* however many channel variables that mode produces, SET each, then return
|
|
9
9
|
* so the dialplan resumes at Stasis(mediacontroller).
|
|
10
10
|
*
|
|
11
|
-
* Always fail-open:
|
|
12
|
-
* AudioSocket
|
|
13
|
-
*
|
|
14
|
-
* exception must never leave the AGI connection hanging.
|
|
11
|
+
* Always fail-open: deadlines bound both the wait for a verdict and the wait
|
|
12
|
+
* for AudioSocket() to return, and the AGI connection is always closed so the
|
|
13
|
+
* dialplan resumes even when no variables could be set.
|
|
15
14
|
*/
|
|
16
15
|
declare function startAgiServer(): AgiServer;
|
|
17
16
|
export { startAgiServer };
|
package/dist/agi/server.js
CHANGED
|
@@ -40,6 +40,11 @@ const TIMED_OUT_RESULT = () => ({
|
|
|
40
40
|
cause: "ML-TIMEOUT",
|
|
41
41
|
latencyMs: 0
|
|
42
42
|
});
|
|
43
|
+
// How long to wait, once the verdict is in, for AudioSocket() to return and
|
|
44
|
+
// free the AGI socket. The audio leg hangs up right after classifying and
|
|
45
|
+
// runProbe shares TIMEOUT_MS, so this only runs out on a genuinely stuck leg.
|
|
46
|
+
const AUDIOSOCKET_RELEASE_MS = 2000;
|
|
47
|
+
const delay = (ms, value) => new Promise((resolve) => setTimeout(() => resolve(value), ms));
|
|
43
48
|
/**
|
|
44
49
|
* Starts the FastAGI listener. Asterisk's `AGI(agi://amd:4573,${AMD_MODE})`
|
|
45
50
|
* blocks the channel here. Per session: read the requested mode from the
|
|
@@ -49,10 +54,9 @@ const TIMED_OUT_RESULT = () => ({
|
|
|
49
54
|
* however many channel variables that mode produces, SET each, then return
|
|
50
55
|
* so the dialplan resumes at Stasis(mediacontroller).
|
|
51
56
|
*
|
|
52
|
-
* Always fail-open:
|
|
53
|
-
* AudioSocket
|
|
54
|
-
*
|
|
55
|
-
* exception must never leave the AGI connection hanging.
|
|
57
|
+
* Always fail-open: deadlines bound both the wait for a verdict and the wait
|
|
58
|
+
* for AudioSocket() to return, and the AGI connection is always closed so the
|
|
59
|
+
* dialplan resumes even when no variables could be set.
|
|
56
60
|
*/
|
|
57
61
|
function startAgiServer() {
|
|
58
62
|
const agi = new AgiServer_1.AgiServer({ port: envs_1.AGI_PORT });
|
|
@@ -71,39 +75,57 @@ function startAgiServer() {
|
|
|
71
75
|
(0, sessionRegistry_1.unregisterPendingClassification)(sessionId);
|
|
72
76
|
});
|
|
73
77
|
logger.verbose("agi call", { sessionId, mode, channel: call.channel });
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
return pending;
|
|
85
|
-
}))();
|
|
86
|
-
work.catch(() => undefined); // no unhandled rejection if the deadline wins
|
|
87
|
-
let result;
|
|
88
|
-
try {
|
|
89
|
-
result = yield Promise.race([work, timeout]);
|
|
90
|
-
}
|
|
91
|
-
catch (err) {
|
|
92
|
-
logger.warn("agi session failed; reporting unknown", {
|
|
78
|
+
const classification = (0, sessionRegistry_1.registerPendingClassification)(sessionId);
|
|
79
|
+
// AudioSocket()'s result says nothing about whether AMD worked: Asterisk
|
|
80
|
+
// before 20.14 returns -1 ("200 result=-1") whenever the remote ends the
|
|
81
|
+
// stream, including the HANGUP this server sends after every
|
|
82
|
+
// classification. It only tells us when the leg is over and the AGI
|
|
83
|
+
// socket is free again.
|
|
84
|
+
const legEnded = call
|
|
85
|
+
.exec("AudioSocket", `${sessionId},${envs_1.AUDIOSOCKET_ADVERTISE_HOST}:${envs_1.AUDIOSOCKET_PORT}`)
|
|
86
|
+
.catch((err) => {
|
|
87
|
+
logger.verbose("AudioSocket() returned", {
|
|
93
88
|
sessionId,
|
|
94
|
-
|
|
89
|
+
result: err.message
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
// The verdict is resolved in-process before the audio leg hangs up, so a
|
|
93
|
+
// leg that ends first (e.g. Asterisk couldn't connect) has no verdict.
|
|
94
|
+
const noVerdict = {
|
|
95
|
+
kind: "unknown",
|
|
96
|
+
cause: "ML-ERROR",
|
|
97
|
+
latencyMs: 0
|
|
98
|
+
};
|
|
99
|
+
const result = yield Promise.race([
|
|
100
|
+
classification,
|
|
101
|
+
legEnded.then(() => noVerdict),
|
|
102
|
+
delay(envs_1.TIMEOUT_MS, TIMED_OUT_RESULT())
|
|
103
|
+
]);
|
|
104
|
+
(0, sessionRegistry_1.unregisterPendingClassification)(sessionId);
|
|
105
|
+
if (result === noVerdict) {
|
|
106
|
+
logger.warn("audiosocket leg ended without a verdict; reporting unknown", {
|
|
107
|
+
sessionId
|
|
95
108
|
});
|
|
96
|
-
result = { kind: "unknown", cause: "ML-ERROR", latencyMs: 0 };
|
|
97
|
-
}
|
|
98
|
-
finally {
|
|
99
|
-
(0, sessionRegistry_1.unregisterPendingClassification)(sessionId);
|
|
100
109
|
}
|
|
110
|
+
// FastAGI is one command at a time: SET VARIABLE can only be sent once
|
|
111
|
+
// AudioSocket() has returned.
|
|
112
|
+
const released = yield Promise.race([
|
|
113
|
+
legEnded.then(() => true),
|
|
114
|
+
delay(AUDIOSOCKET_RELEASE_MS, false)
|
|
115
|
+
]);
|
|
101
116
|
if (hungUp || call.hungup) {
|
|
102
117
|
logger.verbose("agi call hung up mid-analysis; skipping SET VARIABLE", {
|
|
103
118
|
sessionId
|
|
104
119
|
});
|
|
105
120
|
return;
|
|
106
121
|
}
|
|
122
|
+
if (!released) {
|
|
123
|
+
logger.warn("audiosocket leg still open; skipping SET VARIABLE", {
|
|
124
|
+
sessionId
|
|
125
|
+
});
|
|
126
|
+
call.close();
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
107
129
|
const variables = (0, buildAmdVariables_1.buildAmdVariables)(result, mode, envs_1.MIN_CONFIDENCE);
|
|
108
130
|
logger.verbose("agi call variables", { sessionId, mode, variables });
|
|
109
131
|
try {
|
package/dist/probe/collectPcm.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.collectPcm = collectPcm;
|
|
4
|
-
|
|
4
|
+
// Asterisk's AudioSocket() app always streams slin (8 kHz), whatever the
|
|
5
|
+
// channel's native rate.
|
|
6
|
+
const SAMPLE_RATE = 8000;
|
|
5
7
|
const BYTES_PER_SAMPLE = 2; // mono, 16-bit little-endian
|
|
6
8
|
/**
|
|
7
9
|
* Buffers the first `probeMs` of a call's audio (or whatever arrives before
|
package/dist/probe/runProbe.js
CHANGED
|
@@ -31,6 +31,7 @@ exports.runProbe = runProbe;
|
|
|
31
31
|
const logger_1 = require("@fonoster/logger");
|
|
32
32
|
const AmdModel_1 = require("../amd/AmdModel");
|
|
33
33
|
const collectPcm_1 = require("./collectPcm");
|
|
34
|
+
const upsample_1 = require("./upsample");
|
|
34
35
|
const logger = (0, logger_1.getLogger)({ service: "amd", filePath: __filename });
|
|
35
36
|
/**
|
|
36
37
|
* Runs Answering Machine Detection on the leading audio of one AGI/AudioSocket
|
|
@@ -79,7 +80,8 @@ function runProbe(params) {
|
|
|
79
80
|
// The deadline must also bound classification: a cold model load or a hung
|
|
80
81
|
// inference cannot be allowed to stall the AGI session. ONNX Runtime has no
|
|
81
82
|
// cancellation, so the losing promise is left to settle on its own.
|
|
82
|
-
|
|
83
|
+
// The model was trained on 16 kHz input; AudioSocket delivers 8 kHz.
|
|
84
|
+
const classification = classify((0, upsample_1.upsample8kTo16k)(pcm));
|
|
83
85
|
classification.catch(() => undefined); // no unhandled rejection if the deadline wins
|
|
84
86
|
const outcome = yield Promise.race([classification, onDeadline]);
|
|
85
87
|
if (outcome === TIMED_OUT) {
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.upsample8kTo16k = upsample8kTo16k;
|
|
4
|
+
/**
|
|
5
|
+
* Copyright (C) 2025 by Fonoster Inc (https://fonoster.com)
|
|
6
|
+
* http://github.com/fonoster/fonoster
|
|
7
|
+
*
|
|
8
|
+
* This file is part of Fonoster
|
|
9
|
+
*
|
|
10
|
+
* Licensed under the MIT License (the "License");
|
|
11
|
+
* you may not use this file except in compliance with
|
|
12
|
+
* the License. You may obtain a copy of the License at
|
|
13
|
+
*
|
|
14
|
+
* https://opensource.org/licenses/MIT
|
|
15
|
+
*
|
|
16
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
17
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
18
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
19
|
+
* See the License for the specific language governing permissions and
|
|
20
|
+
* limitations under the License.
|
|
21
|
+
*/
|
|
22
|
+
const HALF_TAPS = 16;
|
|
23
|
+
// Half-band windowed-sinc (Blackman) taps at offsets ±(k + 0.5), normalized
|
|
24
|
+
// to unity DC gain. Linear interpolation measurably loses accuracy here: its
|
|
25
|
+
// imaging above 4 kHz is enough to lower the classifier's confidence.
|
|
26
|
+
const TAPS = (() => {
|
|
27
|
+
const taps = new Float64Array(HALF_TAPS);
|
|
28
|
+
let sum = 0;
|
|
29
|
+
for (let k = 0; k < HALF_TAPS; k++) {
|
|
30
|
+
const offset = k + 0.5;
|
|
31
|
+
const sinc = Math.sin(Math.PI * offset) / (Math.PI * offset);
|
|
32
|
+
const u = (offset + HALF_TAPS) / (2 * HALF_TAPS);
|
|
33
|
+
const window = 0.42 - 0.5 * Math.cos(2 * Math.PI * u) + 0.08 * Math.cos(4 * Math.PI * u);
|
|
34
|
+
taps[k] = sinc * window;
|
|
35
|
+
sum += 2 * taps[k];
|
|
36
|
+
}
|
|
37
|
+
return taps.map((t) => t / sum);
|
|
38
|
+
})();
|
|
39
|
+
/** Upsamples 8 kHz s16le mono PCM to 16 kHz s16le mono. */
|
|
40
|
+
function upsample8kTo16k(pcm) {
|
|
41
|
+
const count = Math.floor(pcm.length / 2);
|
|
42
|
+
const out = Buffer.alloc(count * 4);
|
|
43
|
+
const sample = (i) => pcm.readInt16LE(2 * Math.min(Math.max(i, 0), count - 1));
|
|
44
|
+
for (let i = 0; i < count; i++) {
|
|
45
|
+
let interpolated = 0;
|
|
46
|
+
for (let k = 0; k < HALF_TAPS; k++) {
|
|
47
|
+
interpolated += TAPS[k] * (sample(i - k) + sample(i + 1 + k));
|
|
48
|
+
}
|
|
49
|
+
out.writeInt16LE(pcm.readInt16LE(2 * i), 4 * i);
|
|
50
|
+
out.writeInt16LE(Math.max(-32768, Math.min(32767, Math.round(interpolated))), 4 * i + 2);
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fonoster/amd",
|
|
3
|
-
"version": "0.25.
|
|
3
|
+
"version": "0.25.2",
|
|
4
4
|
"description": "Standalone answering-machine-detection sidecar for the Fonoster platform",
|
|
5
5
|
"author": "Pedro Sanders <psanders@fonoster.com>",
|
|
6
6
|
"homepage": "https://github.com/fonoster/fonoster#readme",
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
"dependencies": {
|
|
22
22
|
"@fonoster/common": "^0.25.1",
|
|
23
23
|
"@fonoster/logger": "^0.25.0",
|
|
24
|
-
"@fonoster/streams": "^0.25.
|
|
24
|
+
"@fonoster/streams": "^0.25.2",
|
|
25
25
|
"@fonoster/types": "^0.25.0",
|
|
26
26
|
"onnxruntime-node": "1.21.1",
|
|
27
27
|
"uuid": "^11.0.3"
|
|
@@ -43,5 +43,5 @@
|
|
|
43
43
|
"bugs": {
|
|
44
44
|
"url": "https://github.com/fonoster/fonoster/issues"
|
|
45
45
|
},
|
|
46
|
-
"gitHead": "
|
|
46
|
+
"gitHead": "6a1a458326108fd79f670c5b0470f15d957ae867"
|
|
47
47
|
}
|