Docs / mod_amd_ws (FreeSWITCH)
A compiled FreeSWITCH module that streams the first seconds of an answered call to the AMDY.IO answering machine detection service over WebSocket and sets AMDSTATUS / AMDCAUSE on the channel. It speaks the same protocol and result vocabulary as the AMD_WS() module for Asterisk. It also contains the energy/word-count detector of mod_amd, so you can run without the network, compare both detectors on live traffic, or fall back to it when the service is unreachable. Current version: v1.0.0.
| Item | Requirement |
|---|---|
| FreeSWITCH | 1.10.x or 1.11.x. Tested on 1.10.11 (Alpine/musl packages) and 1.10.12 (source build on Ubuntu 22.04/glibc). |
| Dependencies | OpenSSL, which FreeSWITCH already links. No libwebsockets, no libks. |
| Headers | The installer finds them: pkg-config, the distribution dev package, or the source tarball of the exact running version. FreeSWITCH is never recompiled or restarted. |
| Network | Outbound TCP to api.amdy.io port 2700 from the FreeSWITCH server, about 128 kbit/s up per concurrent detection. |
| Media | Audio must flow through FreeSWITCH (no bypass_media). |
On the FreeSWITCH server, as root:
curl -fsSL https://raw.githubusercontent.com/nikvb/mod_amd_ws/main/install.sh | bash -s -- -yThe installer detects the running FreeSWITCH, installs the compiler and OpenSSL headers, and finds the FreeSWITCH headers. It builds the module, checks it (no undefined symbols, only the module interface exported, same OpenSSL as the switch) and installs it with a backup of the old one. It then creates amd_ws.conf.xml if absent, adds the module to modules.conf.xml and loads it. It never hangs up calls and never restarts FreeSWITCH. Everything is logged to /var/log/mod_amd_ws-install.log.
# show the plan only, change nothing
curl -fsSL https://raw.githubusercontent.com/nikvb/mod_amd_ws/main/install.sh | bash -s -- --dry-run
# set the endpoint in a new amd_ws.conf.xml
curl -fsSL https://raw.githubusercontent.com/nikvb/mod_amd_ws/main/install.sh | bash -s -- -y --host api.amdy.io --port 2700
# remove the module (amd_ws.conf.xml is kept)
curl -fsSL https://raw.githubusercontent.com/nikvb/mod_amd_ws/main/install.sh | bash -s -- --uninstall| Option | Meaning |
|---|---|
| -y, --yes | Install build packages without asking. |
| --dry-run | Detect and print the plan. Changes nothing, needs no root. |
| --build-only | Build ./mod_amd_ws.so and stop. |
| --host H, --port P, --tls | Endpoint for a new amd_ws.conf.xml (default api.amdy.io:2700). An existing file is never changed. |
| --no-config | Do not create amd_ws.conf.xml. |
| --no-autoload | Do not edit modules.conf.xml. |
| --no-load | Do not load or reload in the running switch. |
| --moddir DIR, --confdir DIR | Override detection. |
| --fs-src DIR | Use headers from this FreeSWITCH source tree (offline hosts). |
| --no-devpkg | Skip the distribution dev package; go straight to the source tarball. |
| --uninstall | Unload (refused while in use), remove the module and the autoload line. amd_ws.conf.xml is kept. |
Offline host: copy install.sh to it. It needs no network if the build packages and FreeSWITCH headers are present; otherwise pass --fs-src /path/to/freeswitch-<version>.
fs_cli -x 'module_exists mod_amd_ws' # true
fs_cli -x 'amd_ws status' # endpoint, settings, counters
fs_cli -x 'show application' | grep amd_ws<extension name="outbound_amd">
<condition field="destination_number" expression="^amd$">
<action application="amd_ws" data=""/>
<action application="log" data="INFO AMD ${AMDSTATUS}/${AMDCAUSE} stats=${AMDSTATS}"/>
<action application="execute_extension" data="amd_${AMDSTATUS} XML ${context}"/>
</condition>
</extension>With an empty argument the endpoint and timings come from amd_ws.conf.xml. The full form is the same as Asterisk's AMD_WS():
amd_ws [host[,port[,vid[,timeout_ms[,playfile[,options]]]]]]| Argument | Default | Meaning |
|---|---|---|
| host | host from amd_ws.conf.xml | Service host name or IP. |
| port | from the config (2700) | TCP port. |
| vid | ${amd_ws_vid}, else ${caller_id_name}, else Unknown | Call id sent to the service as VID. |
| timeout_ms | from the config (10000) | Detection window from the first audio frame. |
| playfile | none | Played to the callee while detecting; stopped at the verdict. Any playback target (file, tone_stream://, say:…). |
| options | none | Letters, see below. |
If an argument contains commas (a tone_stream:// spec, for example), use FreeSWITCH's delimiter prefix and separate with |:
<action application="amd_ws" data="^^|api.amdy.io|2700||10000|tone_stream://%(500,500,440);loops=20|d(800)"/>| Letter | Meaning |
|---|---|
| s | TLS (wss://). Certificate verification per tls_verify. |
| d(ms) | Start playfile after this many milliseconds. |
| c(ms) | Connect timeout (default connect_timeout_ms, 10000). |
| p(phone) k(code) i(cid) | Send phone, country_code, caller_id in the config frame. |
| a / A | Answer an unanswered channel (default) / do not answer. With A an unanswered channel gets HUMAN/FATAL_ERROR. |
| v | Trace this call at INFO: connect, each chunk, each reply, verdict, each with +ms. |
| L | Local detector only (no network; mod_amd behaviour). |
| C | Compare: stream and run the local detector; the service decides, the local verdict is reported. |
| F / f | Use the local verdict on CONNECTION_ERROR/PROCESSING_ERROR / do not (overrides local_fallback). |
| n | Accepted for Asterisk compatibility; no effect. |
amd_ws_host, amd_ws_port, amd_ws_vid, amd_ws_timeout_ms, amd_ws_connect_timeout_ms, amd_ws_tls, amd_ws_trace, amd_ws_mode (ws/local/compare), amd_ws_local_fallback, amd_ws_compare_wait_local, amd_ws_phone, amd_ws_country_code, amd_ws_caller_id, amd_ws_playdelay_ms. They override amd_ws.conf.xml; dialplan arguments and options override them. Set them in an originate string, for example:
{amd_ws_phone=3125551212,amd_ws_country_code=1}sofia/gateway/...caller_id is sent automatically as the number the callee sees: effective_caller_id_number, else origination_caller_id_number, else caller_id_number. It is never sent empty or as Unknown, and not at all with send_caller_id=false.
| Variable | Value |
|---|---|
| AMDSTATUS | HUMAN, MACHINE, NOTSURE or HANGUP |
| AMDCAUSE | See the table below. |
| AMDSTATS | <elapsed_ms>-<audio_ms_sent>-<chunks_sent>-<bytes_sent> on every exit |
| AMDRESPONSE | Last text the service sent, printable ASCII, at most 255 characters |
| AMDELAPSED | Milliseconds from the first captured audio frame to the verdict |
| amd_result, amd_cause | Same as AMDSTATUS / AMDCAUSE, so dialplans written for mod_amd keep working |
| amd_ws_source | ws, local, fallback, timeout, error or hangup |
| amd_ws_mode | ws, local or compare |
| amd_ws_connect_ms | WebSocket connect time (when connected) |
| amd_ws_local_result, amd_ws_local_cause, amd_ws_local_ms | Local detector verdict and when it decided (local/compare modes, or with fallback) |
| amd_ws_fallback_from | The service error that made the local verdict the result |
| AMDPHONE, AMDCOUNTRYCODE | The phone / country code sent, when known |
| AMDSTATUS | AMDCAUSE | When |
|---|---|---|
HUMAN | HUMAN | The service reply contains HUMAN. |
MACHINE | the reply text (MACHINE, AMD, AMD_DETECTED, …) | The reply contains AMD (not AMDY) or MACHINE. |
HUMAN | CONNECTION_ERROR | DNS, TCP, TLS or upgrade failed, or took longer than connect_timeout_ms. |
HUMAN | PROCESSING_ERROR | The connection broke or the server closed it before a verdict. |
HUMAN | FATAL_ERROR | The module could not run (channel not answered with A, no media, out of resources). |
NOTSURE | SERVER_TIMEOUT | timeout_ms passed with audio sent and no verdict. |
NOTSURE | NOAUDIODATA-<ms> | timeout_ms passed and the channel delivered no audio at all. |
NOTSURE | EOF_INCONCLUSIVE / EOF_ERROR | Media stopped after some speech; the service's answer to {"eof":1} had no verdict / no answer within eof_wait_ms. |
HANGUP | HANGUP | The channel hung up during detection. |
NOTSURE | STOPPED | Background detection stopped with uuid_amd_ws <uuid> stop. |
| as detected | HUMAN, INITIALSILENCE, MAXWORDLENGTH, MAXWORDS, LONGGREETING, TOOLONG | Local detector result (mode local, or the fallback). |
The three error causes return HUMAN so a call is never lost when the service is down. Branch on them to use a local fallback in the dialplan:
<action application="execute_extension" data="${cond(${AMDCAUSE} == CONNECTION_ERROR ? amd_fallback : amd_${AMDSTATUS})} XML ${context}"/>or let the module do it with local_fallback=true or option F.
fs_cli -x 'uuid_amd_ws <uuid> start api.amdy.io,2700,,10000'
fs_cli -x 'uuid_amd_ws <uuid> stop'The detection runs on a media bug while the channel does whatever it is doing: a bridge, an IVR or playback. When the verdict is known, the module sets the same variables and fires the event below. It also runs every channel variable named api_on_amd_ws_result* as an API command. Its arguments are expanded when it runs. set expands its value once more, so write \\\${...} for a variable that must be expanded only when the command runs:
<action application="set" data="api_on_amd_ws_result=uuid_transfer ${uuid} amd_\\\${AMDSTATUS} XML default"/>
<action application="set" data="r=${uuid_amd_ws(${uuid} start)}"/>
<action application="bridge" data="..."/>CUSTOM amd_ws::result, once per detection, with the channel's standard headers plus AMD-Status, AMD-Cause, AMD-Response, AMD-Source, AMD-Mode, AMD-VID, AMD-Elapsed, AMD-Stats, AMD-Connect-MS, AMD-First-Audio-MS, AMD-Background, and when the local detector ran AMD-Local-Status, AMD-Local-Cause, AMD-Local-MS, AMD-Fallback-From.
fs_cli -x '/event plain CUSTOM amd_ws::result'<conf_dir>/autoload_configs/amd_ws.conf.xml. Every parameter, with its default and meaning, is in the sample conf/amd_ws.conf.xml. Apply changes with fs_cli -x 'amd_ws reload'; calls in progress keep their settings.
| Parameter | Default | Meaning |
|---|---|---|
| host, port, path | 127.0.0.1 (installer and sample: api.amdy.io), 2700, / | Endpoint. |
| tls, tls_verify, tls_check_hostname, tls_cafile | false, true, true, system bundle | TLS. |
| mode | ws | ws, local or compare. |
| local_fallback | false | Use the local verdict on connection/processing errors. |
| compare_wait_local | false | Compare mode: return only after the local detector decided too. |
| timeout_ms | 10000 | Detection window. |
| connect_timeout_ms | 10000 | Connect limit. Set 2000–3000 to fall back quickly instead of waiting out a slow DNS lookup. |
| result_grace_ms | 0 | Extra wait for a verdict after the window. |
| send_schedule | 500,1000,1500,2000,3000,…,9000 | Send marks (ms from the first audio frame). |
| chunk_bytes, fallback_interval_ms | 8000, 1000 | Sends after the last mark. |
| eof_no_audio_streak, eof_wait_ms | 2, 3000 | EOF finalisation when media stops. |
| send_caller_id | true | Send caller_id. |
| extra_config | empty | JSON object merged into the config frame. |
| answer, playdelay_ms, trace | true, 0, false | Channel handling, logging. |
| silence_threshold … maximum_word_length | mod_amd defaults | Local detector (same names as amd.conf.xml). |
amd_ws answers the channel if needed and attaches a read-only media bug. Each frame the callee sends is resampled to 8 kHz if needed. Frames the core fills because no RTP arrived count as "no audio".ws://host:port/, sends the config frame and sends the audio collected at each schedule mark as one binary frame. A reply containing HUMAN means human, then AMD or MACHINE means machine; anything else is an acknowledgement.connect_timeout_ms / timeout_ms.{"eof":1} and CLOSE 1000 and releases the socket.Measured on real FreeSWITCH calls (1.10.12, Xeon E5-2420 @ 1.90 GHz) with the benchmark in the repository. Full tables and method: performance.md.
| Native energy detection (mod_amd, amd_ws option L) | WebSocket stream (amd_ws) | |
|---|---|---|
| How it decides | Counts words and silences by frame energy with fixed rules. | The service classifies the audio content; the module forwards it at 500, 1000, 1500, 2000, 3000 ms … and applies the reply. |
| Latency | p50 ≈ 2.0 s, p99 up to 4.9 s. A human is confirmed only after 800 ms of silence following "hello". | The verdict arrives when the service answers. With a verdict at the 1500 ms mark: p50 1.56–1.58 s, p99 ≤ 1.82 s at 200 concurrent calls, plus inference and network round trip. |
| Module overhead on the verdict | none (in-process) | 1–2 ms |
| False positives | Structural: long human greetings, quiet lines, background noise and short or late voicemail greetings are misclassified by design. | Those of the service. The module adds none; on errors it returns HUMAN, or the local verdict with local_fallback. |
| FreeSWITCH CPU per detection | ≈ 5–7 ms (0.15–0.26 % of a core per concurrent call) | ≈ 8–9 ms (0.38–0.50 % of a core per concurrent call) |
| Memory | ≈ 0 extra | ≈ 0.27 MB per concurrent detection |
| Network | none | 16 kB/s up per detecting call; one TCP (TLS) connection per call |
| Concurrent detections | Energy detection | Streaming | Streaming bandwidth up |
|---|---|---|---|
| 50 | ≈ 0.1 core | ≈ 0.25 core, +14 MB | 6.4 Mbit/s |
| 200 | ≈ 0.5 core | ≈ 1 core, +54 MB | 26 Mbit/s |
| 500 | ≈ 1.3 core | ≈ 2.5 cores, +135 MB | 64 Mbit/s |
Worst case on a 2012 CPU. A detection lasts about 1.5–3 s at the start of an answered call, so 200 concurrent detections is a very large dialer.
Bottom line. Neither approach is expensive for the media server. The difference that matters is accuracy: an energy detector has failure modes you cannot tune away, and a content classifier does not share them. The widely used seanbright/mod_amd also has a frame-energy bug that makes its verdicts wrong and unrepeatable; amd_ws option L runs the same algorithm with the same parameter names, without that bug.
amd_ws in ws mode, with a fallback branch on CONNECTION_ERROR / PROCESSING_ERROR / FATAL_ERROR, or local_fallback=true.mode=compare on some campaigns for a few days, then compare AMDSTATUS with amd_ws_local_result and with agent dispositions.mod_amd with amd_ws … L if you need a network-free detector.fs_cli -x 'amd_ws status' # settings, calls in progress, worker threads, counters
fs_cli -x 'console loglevel debug' # + trace=true or option v for per-call event lines
grep 'AMD_WS:' /var/log/freeswitch/freeswitch.log | tail| Symptom | Check |
|---|---|
Every call HUMAN/CONNECTION_ERROR | From the switch: curl -i -N -H 'Connection: Upgrade' -H 'Upgrade: websocket' -H 'Sec-WebSocket-Version: 13' -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' http://api.amdy.io:2700/; firewall; host/port in amd_ws status. The WARNING line names the reason (refused, timeout, TLS certificate, HTTP status). |
NOAUDIODATA-<ms> | The channel delivered no media: early media / answer supervision, a codec with no decoder, or bypass_media. The module needs media through FreeSWITCH. |
unload mod_amd_ws says "in use" | Calls are detecting. Unload or reload when idle; amd_ws status shows calls in progress. |
| load fails with undefined symbols | The module was built against headers of another FreeSWITCH version; rerun the installer on that host. |
Source, tests and benchmark: github.com/nikvb/mod_amd_ws. Detailed documents:
Need help? Email [email protected] with:
fs_cli -x version)/var/log/mod_amd_ws-install.log)fs_cli -x 'amd_ws status'AMD_WS: log lines for one call, or its trace (option v)