@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.
@@ -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: a `setTimeout` bounds the whole session even if the
12
- * AudioSocket side never resolves, and a top-level try/catch guarantees the
13
- * SET VARIABLE commands are still attempted before the handler returns — an
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 };
@@ -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: a `setTimeout` bounds the whole session even if the
53
- * AudioSocket side never resolves, and a top-level try/catch guarantees the
54
- * SET VARIABLE commands are still attempted before the handler returns — an
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 timeout = new Promise((resolve) => {
75
- setTimeout(() => resolve(TIMED_OUT_RESULT()), envs_1.TIMEOUT_MS);
76
- });
77
- // Wrapped as one promise so the deadline bounds the EXEC itself, not just
78
- // the wait that follows it — otherwise a stuck/unreachable AudioSocket
79
- // connection would hang here forever before the race below is ever
80
- // reached, despite the fail-open guarantee described above.
81
- const work = (() => __awaiter(this, void 0, void 0, function* () {
82
- const pending = (0, sessionRegistry_1.registerPendingClassification)(sessionId);
83
- yield call.exec("AudioSocket", `${sessionId},${envs_1.AUDIOSOCKET_ADVERTISE_HOST}:${envs_1.AUDIOSOCKET_PORT}`);
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
- error: err.message
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 {
@@ -1,7 +1,9 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.collectPcm = collectPcm;
4
- const SAMPLE_RATE = 16000;
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
@@ -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
- const classification = classify(pcm);
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,3 @@
1
+ /** Upsamples 8 kHz s16le mono PCM to 16 kHz s16le mono. */
2
+ declare function upsample8kTo16k(pcm: Buffer): Buffer;
3
+ export { upsample8kTo16k };
@@ -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.1",
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.0",
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": "7a3786a2704ae379211fbd350a3f6e8d913a5fd6"
46
+ "gitHead": "6a1a458326108fd79f670c5b0470f15d957ae867"
47
47
  }