Docs / mod_amd_ws (FreeSWITCH)

mod_amd_ws: Native FreeSWITCH Module

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.

Requirements

ItemRequirement
FreeSWITCH1.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).
DependenciesOpenSSL, which FreeSWITCH already links. No libwebsockets, no libks.
HeadersThe installer finds them: pkg-config, the distribution dev package, or the source tarball of the exact running version. FreeSWITCH is never recompiled or restarted.
NetworkOutbound TCP to api.amdy.io port 2700 from the FreeSWITCH server, about 128 kbit/s up per concurrent detection.
MediaAudio must flow through FreeSWITCH (no bypass_media).

Install (one command)

On the FreeSWITCH server, as root:

curl -fsSL https://raw.githubusercontent.com/nikvb/mod_amd_ws/main/install.sh | bash -s -- -y

The 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.

Preview, custom endpoint, uninstall

# 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

Installer options

OptionMeaning
-y, --yesInstall build packages without asking.
--dry-runDetect and print the plan. Changes nothing, needs no root.
--build-onlyBuild ./mod_amd_ws.so and stop.
--host H, --port P, --tlsEndpoint for a new amd_ws.conf.xml (default api.amdy.io:2700). An existing file is never changed.
--no-configDo not create amd_ws.conf.xml.
--no-autoloadDo not edit modules.conf.xml.
--no-loadDo not load or reload in the running switch.
--moddir DIR, --confdir DIROverride detection.
--fs-src DIRUse headers from this FreeSWITCH source tree (offline hosts).
--no-devpkgSkip the distribution dev package; go straight to the source tarball.
--uninstallUnload (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>.

Verify the install

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

Dialplan

<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]]]]]]
ArgumentDefaultMeaning
hosthost from amd_ws.conf.xmlService host name or IP.
portfrom the config (2700)TCP port.
vid${amd_ws_vid}, else ${caller_id_name}, else UnknownCall id sent to the service as VID.
timeout_msfrom the config (10000)Detection window from the first audio frame.
playfilenonePlayed to the callee while detecting; stopped at the verdict. Any playback target (file, tone_stream://, say:…).
optionsnoneLetters, 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)"/>

Options

LetterMeaning
sTLS (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 / AAnswer an unanswered channel (default) / do not answer. With A an unanswered channel gets HUMAN/FATAL_ERROR.
vTrace this call at INFO: connect, each chunk, each reply, verdict, each with +ms.
LLocal detector only (no network; mod_amd behaviour).
CCompare: stream and run the local detector; the service decides, the local verdict is reported.
F / fUse the local verdict on CONNECTION_ERROR/PROCESSING_ERROR / do not (overrides local_fallback).
nAccepted for Asterisk compatibility; no effect.

Channel variables read

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.

Results: channel variables

VariableValue
AMDSTATUSHUMAN, MACHINE, NOTSURE or HANGUP
AMDCAUSESee the table below.
AMDSTATS<elapsed_ms>-<audio_ms_sent>-<chunks_sent>-<bytes_sent> on every exit
AMDRESPONSELast text the service sent, printable ASCII, at most 255 characters
AMDELAPSEDMilliseconds from the first captured audio frame to the verdict
amd_result, amd_causeSame as AMDSTATUS / AMDCAUSE, so dialplans written for mod_amd keep working
amd_ws_sourcews, local, fallback, timeout, error or hangup
amd_ws_modews, local or compare
amd_ws_connect_msWebSocket connect time (when connected)
amd_ws_local_result, amd_ws_local_cause, amd_ws_local_msLocal detector verdict and when it decided (local/compare modes, or with fallback)
amd_ws_fallback_fromThe service error that made the local verdict the result
AMDPHONE, AMDCOUNTRYCODEThe phone / country code sent, when known
AMDSTATUSAMDCAUSEWhen
HUMANHUMANThe service reply contains HUMAN.
MACHINEthe reply text (MACHINE, AMD, AMD_DETECTED, …)The reply contains AMD (not AMDY) or MACHINE.
HUMANCONNECTION_ERRORDNS, TCP, TLS or upgrade failed, or took longer than connect_timeout_ms.
HUMANPROCESSING_ERRORThe connection broke or the server closed it before a verdict.
HUMANFATAL_ERRORThe module could not run (channel not answered with A, no media, out of resources).
NOTSURESERVER_TIMEOUTtimeout_ms passed with audio sent and no verdict.
NOTSURENOAUDIODATA-<ms>timeout_ms passed and the channel delivered no audio at all.
NOTSUREEOF_INCONCLUSIVE / EOF_ERRORMedia stopped after some speech; the service's answer to {"eof":1} had no verdict / no answer within eof_wait_ms.
HANGUPHANGUPThe channel hung up during detection.
NOTSURESTOPPEDBackground detection stopped with uuid_amd_ws <uuid> stop.
as detectedHUMAN, INITIALSILENCE, MAXWORDLENGTH, MAXWORDS, LONGGREETING, TOOLONGLocal 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.

Background detection (bridge or IVR keeps running)

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="..."/>

Event

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'

Configuration

<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.

ParameterDefaultMeaning
host, port, path127.0.0.1 (installer and sample: api.amdy.io), 2700, /Endpoint.
tls, tls_verify, tls_check_hostname, tls_cafilefalse, true, true, system bundleTLS.
modewsws, local or compare.
local_fallbackfalseUse the local verdict on connection/processing errors.
compare_wait_localfalseCompare mode: return only after the local detector decided too.
timeout_ms10000Detection window.
connect_timeout_ms10000Connect limit. Set 2000–3000 to fall back quickly instead of waiting out a slow DNS lookup.
result_grace_ms0Extra wait for a verdict after the window.
send_schedule500,1000,1500,2000,3000,…,9000Send marks (ms from the first audio frame).
chunk_bytes, fallback_interval_ms8000, 1000Sends after the last mark.
eof_no_audio_streak, eof_wait_ms2, 3000EOF finalisation when media stops.
send_caller_idtrueSend caller_id.
extra_configemptyJSON object merged into the config frame.
answer, playdelay_ms, tracetrue, 0, falseChannel handling, logging.
silence_threshold … maximum_word_lengthmod_amd defaultsLocal detector (same names as amd.conf.xml).

How it works

  • 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".
  • A worker thread per call connects to 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.
  • The channel thread only moves media or runs the playback. It never waits on the network, so a slow or dead service cannot stall a call beyond connect_timeout_ms / timeout_ms.
  • On every exit the worker sends {"eof":1} and CLOSE 1000 and releases the socket.

Native mod_amd vs streaming

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 decidesCounts 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.
Latencyp50 ≈ 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 verdictnone (in-process)1–2 ms
False positivesStructural: 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
Networknone16 kB/s up per detecting call; one TCP (TLS) connection per call
Concurrent detectionsEnergy detectionStreamingStreaming bandwidth up
50≈ 0.1 core≈ 0.25 core, +14 MB6.4 Mbit/s
200≈ 0.5 core≈ 1 core, +54 MB26 Mbit/s
500≈ 1.3 core≈ 2.5 cores, +135 MB64 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.

Recommendation

  • Stream for accuracy: amd_ws in ws mode, with a fallback branch on CONNECTION_ERROR / PROCESSING_ERROR / FATAL_ERROR, or local_fallback=true.
  • Prove it on your traffic first: run mode=compare on some campaigns for a few days, then compare AMDSTATUS with amd_ws_local_result and with agent dispositions.
  • Replace mod_amd with amd_ws … L if you need a network-free detector.
  • Size the uplink at 128 kbit/s per concurrent detection.

Troubleshooting

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
SymptomCheck
Every call HUMAN/CONNECTION_ERRORFrom 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 symbolsThe module was built against headers of another FreeSWITCH version; rerun the installer on that host.

Full reference

Source, tests and benchmark: github.com/nikvb/mod_amd_ws. Detailed documents:

  • Performance report: latency, false positives, CPU, resilience, how to reproduce
  • Wire protocol: config frame, audio schedule, replies, EOF finalisation, timeouts
  • Architecture: threads, media bug, foreground vs background
  • Installer: what it does, options, tested platforms, offline hosts

Support

Need help? Email [email protected] with:

  • FreeSWITCH version (fs_cli -x version)
  • Installer log (/var/log/mod_amd_ws-install.log)
  • The output of fs_cli -x 'amd_ws status'
  • The AMD_WS: log lines for one call, or its trace (option v)
mod_amd_ws: native FreeSWITCH module for AMDY answering machine detection | AMDY.IO