{"id":154,"date":"2026-05-20T15:16:21","date_gmt":"2026-05-20T15:16:21","guid":{"rendered":"https:\/\/phonesstillexist.com\/?p=154"},"modified":"2026-05-20T15:16:25","modified_gmt":"2026-05-20T15:16:25","slug":"building-a-voice-ai-agent-with-freeswitch-part-2-streaming-call-audio-out-of-freeswitch","status":"publish","type":"post","link":"https:\/\/phonesstillexist.com\/index.php\/2026\/05\/20\/building-a-voice-ai-agent-with-freeswitch-part-2-streaming-call-audio-out-of-freeswitch\/","title":{"rendered":"Building a Voice AI Agent with FreeSWITCH, Part 2: Streaming Call Audio Out of FreeSWITCH"},"content":{"rendered":"\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"538\" src=\"https:\/\/phonesstillexist.com\/wp-content\/uploads\/2026\/05\/part2-1024x538.png\" alt=\"\" class=\"wp-image-155\" srcset=\"https:\/\/phonesstillexist.com\/wp-content\/uploads\/2026\/05\/part2-1024x538.png 1024w, https:\/\/phonesstillexist.com\/wp-content\/uploads\/2026\/05\/part2-300x158.png 300w, https:\/\/phonesstillexist.com\/wp-content\/uploads\/2026\/05\/part2-768x403.png 768w, https:\/\/phonesstillexist.com\/wp-content\/uploads\/2026\/05\/part2.png 1200w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\"><em>Part 2 of a series. In Part 1 we mapped the architecture: caller \u2192 SIP \u2192 FreeSWITCH \u2192 audio bridge \u2192 STT \u2192 LLM \u2192 TTS \u2192 back into the call. Here we build the first real piece \u2014 getting live call audio out of FreeSWITCH and into a process you control. No AI yet. The goal is to prove the plumbing works.<\/em><\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<p class=\"wp-block-paragraph\">If you haven&#8217;t installed and hardened FreeSWITCH yet, start there: <a href=\"https:\/\/phonesstillexist.com\/index.php\/2026\/04\/24\/installing-freeswitch-pick-your-path-binary-packages-or-source-build\/\">[Installing FreeSWITCH]<\/a> and <a href=\"https:\/\/phonesstillexist.com\/index.php\/2026\/04\/24\/hardening-freeswitch-a-production-baseline-for-day-one\/\">[Hardening FreeSWITCH: A Production Baseline for Day One]<\/a>. This post assumes you have a hardened FreeSWITCH binary install on Debian 12, with extension 1001 (or any other internal extension) registered to a softphone you can dial from.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">By the end of this post, you will have:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>A FreeSWITCH module called mod_audio_stream installed and loaded<\/li>\n\n\n\n<li>A WebSocket server running in Node.js that receives streamed call audio<\/li>\n\n\n\n<li>A dialplan extension that wires the two together<\/li>\n\n\n\n<li>A .wav file on disk containing audio you spoke into your softphone<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">That last item is the proof. When you can play it back and hear yourself, the plumbing works.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><em>All files referenced in this post are also available in the GitHub repo for the series: <\/em><a href=\"https:\/\/github.com\/thevoiceguy\/freeswitch-voice-ai-series\">https:\/\/github.com\/thevoiceguy\/freeswitch-voice-ai-series<\/a><em> \u2014 clone the repo if you&#8217;d rather copy files than paste from the browser.<\/em><\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h3 class=\"wp-block-heading\"><strong>A note on the module landscape<\/strong><\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">If you&#8217;ve read other tutorials about FreeSWITCH and WebSockets, you&#8217;ve almost certainly seen references to mod_audio_fork from the drachtio\/drachtio-freeswitch-modules repo.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">That repo is gone.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">It 404s. The forks that remain are unmaintained, and the module itself depended on a custom-compiled FreeSWITCH with libwebsockets support \u2014 which the binary install does not have. Every tutorial pointing at mod_audio_fork is broken.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The successor is<a href=\"https:\/\/github.com\/amigniter\/mod_audio_stream\"> mod_audio_stream<\/a>, maintained by amigniter and explicitly designed to work against stock binary FreeSWITCH. It ships as a prebuilt Debian 12 package. The community edition is free for up to 10 concurrent streaming channels \u2014 plenty for a tutorial and most small production deployments.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">That is what we use here.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h3 class=\"wp-block-heading\"><strong>What we&#8217;re building<\/strong><\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Softphone (1001)<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">&nbsp;&nbsp;&nbsp;\u2502 SIP INVITE<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">&nbsp;&nbsp;&nbsp;\u25bc<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">FreeSWITCH (your hardened box)<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">&nbsp;&nbsp;&nbsp;\u2502 dialplan matches &#8220;9999&#8221;<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">&nbsp;&nbsp;&nbsp;\u2502 Lua script invokes uuid_audio_stream<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">&nbsp;&nbsp;&nbsp;\u25bc<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">mod_audio_stream<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">&nbsp;&nbsp;&nbsp;\u2502 8 kHz mono linear PCM<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">&nbsp;&nbsp;&nbsp;\u2502 over WebSocket<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">&nbsp;&nbsp;&nbsp;\u25bc<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Node.js WebSocket server (localhost:8080)<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">&nbsp;&nbsp;&nbsp;\u2502 writes raw PCM<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">&nbsp;&nbsp;&nbsp;\u25bc<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">call-NNNNNN.raw&nbsp; \u2192&nbsp; call-NNNNNN.wav<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">One direction only. The caller speaks; the audio leaves FreeSWITCH and lands in a file. We are not yet sending audio back into the call. Bidirectional audio is the next post&#8217;s problem.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h3 class=\"wp-block-heading\"><strong>Step 1 \u2014 Install Node.js<\/strong><\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Debian 12&#8217;s default Node package is too old. Install Node 20 from NodeSource:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>sudo apt-get update\n\nsudo apt-get install -y curl ca-certificates gnupg\n\ncurl -fsSL https:\/\/deb.nodesource.com\/setup_20.x | sudo -E bash -\n\nsudo apt-get install -y nodejs<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Verify:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>node --version\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">You should see something starting with v20.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h3 class=\"wp-block-heading\"><strong>Step 2 \u2014 Install the WebSocket server<\/strong><\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Create a working directory:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>sudo mkdir -p \/opt\/audio-fork-server\n\ncd \/opt\/audio-fork-server\n\nsudo npm init -y\n\nsudo npm install ws<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">A note on permissions: throughout this post, we run the WebSocket server with sudo because it lives in \/opt, a system directory, and writes its .raw and .wav files there as root. That&#8217;s fine for a development walkthrough, but it&#8217;s not what you&#8217;d do in production \u2014 in Part 5, we&#8217;ll switch the server to run under a dedicated service user with minimal permissions.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Create the server script:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>sudo nano \/opt\/audio-fork-server\/audio-fork-server.js<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Paste this in:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>\/\/ audio-fork-server.js\n\n\/\/\n\n\/\/ Minimal WebSocket server for receiving streamed call audio from\n\n\/\/ FreeSWITCH's mod_audio_stream. Writes received audio to a .wav\n\n\/\/ file you can play back to confirm the plumbing works.\n\nconst fs = require('fs');\n\nconst path = require('path');\n\nconst { WebSocketServer } = require('ws');\n\nconst PORT&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; = 8080;\n\nconst SAMPLE_RATE &nbsp; &nbsp; = 8000; &nbsp; \/\/ Hz, must match the dialplan\n\nconst CHANNELS&nbsp; &nbsp; &nbsp; &nbsp; = 1; &nbsp; &nbsp; &nbsp; \/\/ mono\n\nconst BITS_PER_SAMPLE = 16;&nbsp; &nbsp; &nbsp; \/\/ linear PCM\n\nconst wss = new WebSocketServer({ port: PORT });\n\nconsole.log(`Listening for FreeSWITCH audio on ws:\/\/0.0.0.0:${PORT}`);\n\nwss.on('connection', (ws, req) =&gt; {\n\n&nbsp;&nbsp;const filename = `call-${Date.now()}.raw`;\n\n&nbsp;&nbsp;const filepath = path.resolve(filename);\n\n&nbsp;&nbsp;const stream &nbsp; = fs.createWriteStream(filepath);\n\n&nbsp;&nbsp;let bytesReceived = 0;\n\n&nbsp;&nbsp;console.log(`&#91;${filename}] connection from ${req.socket.remoteAddress}`);\n\n&nbsp;&nbsp;ws.on('message', (data, isBinary) =&gt; {\n\n&nbsp;&nbsp;&nbsp;&nbsp;if (!isBinary) {\n\n&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;console.log(`&#91;${filename}] text frame: ${data.toString().slice(0, 200)}`);\n\n&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;return;\n\n&nbsp;&nbsp;&nbsp;&nbsp;}\n\n&nbsp;&nbsp;&nbsp;&nbsp;stream.write(data);\n\n&nbsp;&nbsp;&nbsp;&nbsp;bytesReceived += data.length;\n\n&nbsp;&nbsp;});\n\n&nbsp;&nbsp;ws.on('close', () =&gt; {\n\n&nbsp;&nbsp;&nbsp;&nbsp;stream.end(() =&gt; {\n\n&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;const wavPath = filepath.replace(\/\\.raw$\/, '.wav');\n\n&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;writeWav(filepath, wavPath, SAMPLE_RATE, CHANNELS, BITS_PER_SAMPLE);\n\n&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;const seconds = bytesReceived \/ (SAMPLE_RATE * CHANNELS * (BITS_PER_SAMPLE \/ 8));\n\n&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;console.log(`&#91;${filename}] closed: ${bytesReceived} bytes (${seconds.toFixed(1)}s) \u2192 ${wavPath}`);\n\n&nbsp;&nbsp;&nbsp;&nbsp;});\n\n&nbsp;&nbsp;});\n\n&nbsp;&nbsp;ws.on('error', (err) =&gt; console.error(`&#91;${filename}] error:`, err.message));\n\n});\n\nfunction writeWav(rawPath, wavPath, rate, channels, bits) {\n\n&nbsp;&nbsp;const pcm&nbsp; &nbsp; &nbsp; &nbsp; = fs.readFileSync(rawPath);\n\n&nbsp;&nbsp;const byteRate &nbsp; = rate * channels * (bits \/ 8);\n\n&nbsp;&nbsp;const blockAlign = channels * (bits \/ 8);\n\n&nbsp;&nbsp;const header &nbsp; &nbsp; = Buffer.alloc(44);\n\n&nbsp;&nbsp;header.write('RIFF', 0);\n\n&nbsp;&nbsp;header.writeUInt32LE(36 + pcm.length, 4);\n\n&nbsp;&nbsp;header.write('WAVE', 8);\n\n&nbsp;&nbsp;header.write('fmt ', 12);\n\n&nbsp;&nbsp;header.writeUInt32LE(16, 16);\n\n&nbsp;&nbsp;header.writeUInt16LE(1, 20);\n\n&nbsp;&nbsp;header.writeUInt16LE(channels, 22);\n\n&nbsp;&nbsp;header.writeUInt32LE(rate, 24);\n\n&nbsp;&nbsp;header.writeUInt32LE(byteRate, 28);\n\n&nbsp;&nbsp;header.writeUInt16LE(blockAlign, 32);\n\n&nbsp;&nbsp;header.writeUInt16LE(bits, 34);\n\n&nbsp;&nbsp;header.write('data', 36);\n\n&nbsp;&nbsp;header.writeUInt32LE(pcm.length, 40);\n\n&nbsp;&nbsp;fs.writeFileSync(wavPath, Buffer.concat(&#91;header, pcm]));\n\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The script does three things: accepts WebSocket connections, writes incoming audio to a .raw file, and wraps that file in a WAV header on disconnect so you can play it back. About 60 lines of actual code, one dependency.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Two things worth knowing about it. The script logs any text frames from mod_audio_stream (some configurations send a metadata header on connection) but doesn&#8217;t write them to the audio file. Every binary frame is raw PCM and gets written. The WAV header is written at the end, not the beginning, because the header includes the file&#8217;s total size and we don&#8217;t know that until the call hangs up.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Don&#8217;t start the server yet. We&#8217;ll start it in Step 8.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h3 class=\"wp-block-heading\"><strong>Step 3 \u2014 Install mod_audio_stream<\/strong><\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">mod_audio_stream ships as a prebuilt .deb for Debian 12. The following one-liner finds the latest release on GitHub and downloads it:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>cd \/tmp\n\ncurl -fsSL https:\/\/api.github.com\/repos\/amigniter\/mod_audio_stream\/releases\/latest \\\n\n&nbsp;&nbsp;| grep \"browser_download_url.*deb\" \\\n\n&nbsp;&nbsp;| head -1 \\\n\n&nbsp;&nbsp;| cut -d'\"' -f4 \\\n\n&nbsp;&nbsp;| xargs wget<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Then install the dependencies and the package:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>sudo apt-get install -y libssl-dev zlib1g-dev libevent-dev libspeexdsp-dev\n\nsudo apt install -y .\/mod-audio-stream_*.deb<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The apt install .\/&lt;file&gt; form (with the .\/ prefix) tells apt to install a local .deb and also pull in any missing dependencies automatically.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Verify the module landed:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>ls -la \/usr\/lib\/freeswitch\/mod\/mod_audio_stream.so<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">You should see the file. If you don&#8217;t, the install failed silently \u2014 check the apt output for errors.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h3 class=\"wp-block-heading\"><strong>Step 4 \u2014 Load mod_audio_stream and verify<\/strong><\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Add the load directive to FreeSWITCH&#8217;s autoload config so it loads at boot:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>sudo sed -i '\/&lt;\\\/modules&gt;\/i \\&nbsp; &nbsp; &lt;load module=\"mod_audio_stream\"\/&gt;' \\\n\n&nbsp;&nbsp;\/etc\/freeswitch\/autoload_configs\/modules.conf.xml<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Verify:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>grep audio_stream \/etc\/freeswitch\/autoload_configs\/modules.conf.xml<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">You should see &lt;load module=&#8221;mod_audio_stream&#8221;\/&gt;.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Now load it live without restarting:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>sudo fs_cli -x \"load mod_audio_stream\"\n\nsudo fs_cli -x \"module_exists mod_audio_stream\"<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The second command must return true. If it returns false, the module loaded with errors \u2014 check journalctl -u freeswitch -n 50 | grep -i stream.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">While we&#8217;re here, confirm the API command is registered:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>sudo fs_cli -x \"show api\" | grep -i audio_stream<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">You should see uuid_audio_stream in the output, with a usage string showing the arguments it accepts.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Important detail you&#8217;ll need in Step 6:<\/strong> notice that show applications | grep audio_stream returns nothing. mod_audio_stream registers an API command, not a dialplan application. You cannot call it directly with &lt;action application=&#8221;audio_stream&#8221; &#8230;\/&gt;. We&#8217;ll work around this in Step 6.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h3 class=\"wp-block-heading\"><strong>Step 5 \u2014 Install mod_lua<\/strong><\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The dialplan needs Lua to invoke the uuid_audio_stream API command in a way that lets the resulting media bug live for the full duration of the call. (We&#8217;ll explain why in Step 6.)<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>sudo apt-get install -y freeswitch-mod-lua\n\nsudo fs_cli -x \"load mod_lua\"\n\nsudo fs_cli -x \"module_exists mod_lua\"<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Should return true.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">If module_exists mod_lua returns true immediately after apt-get install, the package was already there from your binary install \u2014 that&#8217;s fine, the load command was a no-op.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h3 class=\"wp-block-heading\"><strong>Step 6 \u2014 Create the Lua scripts<\/strong><\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">This is the step where most tutorials get it wrong, so it&#8217;s worth understanding <em>why<\/em> we use Lua here.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The intuitive thing is to call the API command from the dialplan with ${api(&#8230;)} substitution:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>&lt;action application=\"set\" data=\"result=${api(uuid_audio_stream ${uuid} start ws:\/\/127.0.0.1:8080 mono 8000)}\"\/&gt;<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">This <em>almost<\/em> works. The WebSocket connection opens. Audio starts flowing. But the audio bug&#8217;s lifetime is tied to the synchronous ${api(&#8230;)} expansion, not to the channel. As soon as the expansion returns, the bug detaches, and the connection closes. You get about 0.3 seconds of audio in your file, then nothing.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Lua doesn&#8217;t have this problem. Calling the same API from a Lua script attaches the bug to the channel correctly, and it lives for the full call.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Find the FreeSWITCH script directory:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>sudo fs_cli -x \"global_getvar script_dir\"<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">On a binary install this is \/usr\/share\/freeswitch\/scripts. Substitute below if yours differs.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Create the start script:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>sudo nano \/usr\/share\/freeswitch\/scripts\/audio_stream_start.lua<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">and paste in:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>local uuid = session:get_uuid()\n\nlocal result = freeswitch.API():executeString(\n\n&nbsp;&nbsp;\"uuid_audio_stream \" .. uuid .. \" start ws:\/\/127.0.0.1:8080 mono 8000\"\n\n)\n\nfreeswitch.consoleLog(\"INFO\", \"audio_stream start: \" .. tostring(result) .. \"\\n\")<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">And the stop script:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>sudo nano \/usr\/share\/freeswitch\/scripts\/audio_stream_stop.lua<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">and paste in:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>local uuid = session:get_uuid()\n\nlocal result = freeswitch.API():executeString(\"uuid_audio_stream \" .. uuid .. \" stop\")\n\nfreeswitch.consoleLog(\"INFO\", \"audio_stream stop: \" .. tostring(result) .. \"\\n\")<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Two lines of real logic each. Get the channel UUID, run the API command against it, log the result. The freeswitch.consoleLog call gives us visibility \u2014 if anything goes wrong, the message will appear in the FreeSWITCH log.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The argument format matters here. Note mono 8000, not mono 8k. The API command&#8217;s argument parser expects the literal numbers 8000 or 16000; 8k is silently rejected.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h3 class=\"wp-block-heading\"><strong>Step 7 \u2014 Create the dialplan extension<\/strong><\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>sudo nano \/etc\/freeswitch\/dialplan\/default\/99_audio_stream_test.xml<\/code><\/pre>\n\n\n\n<pre class=\"wp-block-code\"><code>&lt;include&gt;\n\n&nbsp;&nbsp;&lt;extension name=\"audio_stream_test\"&gt;\n\n&nbsp;&nbsp;&nbsp;&nbsp;&lt;condition field=\"destination_number\" expression=\"^9999$\"&gt;\n\n&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&lt;action application=\"answer\"\/&gt;\n\n&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&lt;action application=\"sleep\" data=\"500\"\/&gt;\n\n&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&lt;action application=\"playback\" data=\"tone_stream:\/\/%(500,0,800)\"\/&gt;\n\n&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&lt;action application=\"set\" data=\"STREAM_BUFFER_SIZE=20\"\/&gt;\n\n&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&lt;action application=\"lua\" data=\"audio_stream_start.lua\"\/&gt;\n\n&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&lt;action application=\"playback\" data=\"ivr\/ivr-please_state_your_name_and_reason_for_calling.wav\"\/&gt;\n\n&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&lt;action application=\"sleep\" data=\"15000\"\/&gt;\n\n&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&lt;action application=\"lua\" data=\"audio_stream_stop.lua\"\/&gt;\n\n&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&lt;action application=\"hangup\"\/&gt;\n\n&nbsp;&nbsp;&nbsp;&nbsp;&lt;\/condition&gt;\n\n&nbsp;&nbsp;&lt;\/extension&gt;\n\n&lt;\/include&gt;<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">What this does, in order: answers the call, brief pause, plays an 800 Hz tone for half a second so the caller knows the call is live, sets the audio buffer size, starts the audio stream via Lua, plays a prompt, gives the caller 15 seconds to talk, stops the stream, hangs up.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The STREAM_BUFFER_SIZE=20 line sets how many 20ms audio frames mod_audio_stream accumulates before flushing them over the WebSocket. 20 frames is a reasonable starting point \u2014 low enough to keep latency tight, high enough to avoid wasting throughput on tiny WebSocket frames. We&#8217;ll revisit this in Part 5 when we tune for production.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Reload the dialplan:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>sudo fs_cli -x \"reloadxml\"<\/code><\/pre>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h3 class=\"wp-block-heading\"><strong>Step 8 \u2014 Test the call<\/strong><\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">In one terminal, start the WebSocket server:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>cd \/opt\/audio-fork-server\nsudo node audio-fork-server.js<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">You should see:<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Listening for FreeSWITCH audio on ws:\/\/0.0.0.0:8080<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Leave it running. From your softphone registered as 1001, dial 9999.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">In the WebSocket terminal, you&#8217;ll see something like:<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">[call-NNNNNNNN.raw] connection from ::ffff:127.0.0.1<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">&#8230;<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">[call-NNNNNNNN.raw] closed: NNNNNN bytes (XX.Xs) \u2192 \/opt\/audio-fork-server\/call-NNNNNNNN.wav<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Some configurations of mod_audio_stream also emit a text metadata frame on connection. If you see one logged as text frame:, that&#8217;s normal \u2014 the script logs it and ignores it for the audio file.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">In your softphone you&#8217;ll hear: a tone, then the IVR prompt asking you to state your name and reason for calling, then 15 seconds of silence (during which you should speak), then the call hangs up.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Speak during the silence.<\/strong> The whole point is to capture your voice.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h3 class=\"wp-block-heading\"><strong>Step 9 \u2014 Listen to what you captured<\/strong><\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The .wav file is on the FreeSWITCH box. Copy it to your local machine.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">You should hear the IVR prompt at the start, followed by your voice for the rest of the recording. A 15-second test call produces a .wav of around 240 KB.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">If you can hear yourself, <strong>Part 2 is done. The plumbing works.<\/strong><\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h3 class=\"wp-block-heading\"><strong>What just happened<\/strong><\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">When you dialed 9999:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li>Your softphone sent a SIP INVITE to FreeSWITCH on port 5060<\/li>\n\n\n\n<li>FreeSWITCH walked the dialplan, matched 9999, and ran the audio_stream_test extension<\/li>\n\n\n\n<li>The call was answered, codecs negotiated (commonly G.722, PCMU, or PCMA depending on your softphone), and the tone played<\/li>\n\n\n\n<li>The Lua script ran, calling the API command uuid_audio_stream &lt;uuid&gt; start ws:\/\/127.0.0.1:8080 mono 8000<\/li>\n\n\n\n<li>mod_audio_stream opened a WebSocket connection from FreeSWITCH to the Node.js server<\/li>\n\n\n\n<li>As you spoke, FreeSWITCH took the inbound RTP audio, downsampled it to 8 kHz mono linear PCM, and pushed it through the WebSocket as binary frames<\/li>\n\n\n\n<li>The Node.js server wrote each frame to a .raw file<\/li>\n\n\n\n<li>After 15 seconds, the second Lua script stopped the stream<\/li>\n\n\n\n<li>The WebSocket closed; the Node.js server wrapped the raw PCM in a WAV header and saved it as .wav<\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\">The audio you played back is the same audio that, in Part 3, will be streamed to Deepgram in real time. We&#8217;ve proved the bridge. Part 3 is about replacing &#8220;write to disk&#8221; with &#8220;forward to STT.&#8221;<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h3 class=\"wp-block-heading\"><strong>Troubleshooting<\/strong><\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">If anything went wrong, you almost certainly hit one of these. The order matches what you&#8217;re most likely to hit first.<\/p>\n\n\n\n<h4 class=\"wp-block-heading\"><strong>&#8220;module_exists mod_audio_stream returns false&#8221;<\/strong><\/h4>\n\n\n\n<p class=\"wp-block-paragraph\">The module didn&#8217;t load. Check:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>sudo journalctl -u freeswitch -n 100 | grep -iE \"audio_stream|stream\"<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Most common cause: a missing dependency. The package install should pull these in, but if it didn&#8217;t:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>sudo apt-get install -y libssl-dev zlib1g-dev libevent-dev libspeexdsp-dev\n\nsudo fs_cli -x \"load mod_audio_stream\"<\/code><\/pre>\n\n\n\n<h4 class=\"wp-block-heading\"><strong>&#8220;Invalid Application audio_stream&#8221;<\/strong><\/h4>\n\n\n\n<p class=\"wp-block-paragraph\">Your dialplan calls &lt;action application=&#8221;audio_stream&#8221; &#8230;&gt; directly. mod_audio_stream doesn&#8217;t register a dialplan application \u2014 only an API command. Use the Lua approach from Step 6.<\/p>\n\n\n\n<h4 class=\"wp-block-heading\"><strong>Call connects, you hear the tone, then the call drops immediately<\/strong><\/h4>\n\n\n\n<p class=\"wp-block-paragraph\">Same root cause as above, or the dialplan is using ${api(&#8230;)} to call uuid_audio_stream. Switch to the Lua scripts.<\/p>\n\n\n\n<h4 class=\"wp-block-heading\"><strong>Files exist but contain only ~5 KB of audio (less than half a second)<\/strong><\/h4>\n\n\n\n<p class=\"wp-block-paragraph\">You&#8217;re using ${api(uuid_audio_stream &#8230;)} in the dialplan. The bug is detaching as soon as the synchronous expansion returns. Use the Lua approach.<\/p>\n\n\n\n<h4 class=\"wp-block-heading\"><strong>Files exist but won&#8217;t play back correctly<\/strong><\/h4>\n\n\n\n<p class=\"wp-block-paragraph\">Most common cause: sample rate mismatch. The dialplan must say mono 8000 (literal number, not 8k) and the Node server must have SAMPLE_RATE = 8000. If you change one, change both.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">If the audio plays at chipmunk speed or chipmunk-slow, the Node server&#8217;s SAMPLE_RATE is wrong relative to what FreeSWITCH is sending.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">If the audio plays as pure static, the format is wrong. The Node server expects raw 16-bit signed little-endian linear PCM, which is what mod_audio_stream sends by default. If you&#8217;ve modified anything about the encoding, change it back.<\/p>\n\n\n\n<h4 class=\"wp-block-heading\"><strong>The Node server never receives a connection<\/strong><\/h4>\n\n\n\n<p class=\"wp-block-paragraph\">Check that it&#8217;s actually listening:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>sudo ss -tlnp | grep 8080<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">You should see node bound to port 8080. If not, the server crashed or isn&#8217;t running.<\/p>\n\n\n\n<h4 class=\"wp-block-heading\"><strong>&#8220;I see SIP scanners hammering my logs&#8221;<\/strong><\/h4>\n\n\n\n<p class=\"wp-block-paragraph\">If your FreeSWITCH box has a public IP, you&#8217;ll see lines like:<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">[WARNING] sofia_reg.c: SIP auth challenge (INVITE) on sofia profile &#8216;internal&#8217; for [&lt;weird number&gt;] from ip &lt;random IP&gt;<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">This is the entire internet trying to register or place calls through your box. <strong>It&#8217;s not a problem.<\/strong> If you followed the hardening guide, you turned on log-auth-failures, which is why you can see them at all. As long as your default passwords are rotated and fail2ban is running, the attackers bounce off your auth and never get a session.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">If you want to see how many have been blocked:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>sudo fail2ban-client status freeswitch<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h3 class=\"wp-block-heading\"><strong>What&#8217;s next<\/strong><\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">In Part 3, we replace &#8220;write to disk&#8221; with &#8220;stream to Deepgram.&#8221; The dialplan and the WebSocket server architecture stay the same \u2014 but instead of accumulating audio in a file, the Node server forwards it to Deepgram&#8217;s streaming API and logs transcripts as the caller speaks.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">That&#8217;s where the system starts to be a voice agent.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><em>Part 3 lands soon!<\/em><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><em>All code from this series is on GitHub: <\/em><a href=\"https:\/\/github.com\/thevoiceguy\/freeswitch-voice-ai-series\">https:\/\/github.com\/thevoiceguy\/freeswitch-voice-ai-series<\/a><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><\/p>\n","protected":false},"excerpt":{"rendered":"<p>Part 2 of a series. In Part 1 we mapped the architecture: caller \u2192 SIP \u2192 FreeSWITCH \u2192 audio bridge \u2192 STT \u2192 LLM \u2192&#8230;<\/p>\n<div class=\"more-link-wrapper\"><a class=\"more-link\" href=\"https:\/\/phonesstillexist.com\/index.php\/2026\/05\/20\/building-a-voice-ai-agent-with-freeswitch-part-2-streaming-call-audio-out-of-freeswitch\/\">Continue reading<span class=\"screen-reader-text\">Building a Voice AI Agent with FreeSWITCH, Part 2: Streaming Call Audio Out of FreeSWITCH<\/span><\/a><\/div>\n","protected":false},"author":1,"featured_media":0,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"iawp_total_views":22,"footnotes":""},"categories":[12],"tags":[13,6,14,5],"class_list":["post-154","post","type-post","status-publish","format-standard","hentry","category-freeswitch","tag-ai","tag-api","tag-freeswitch","tag-websocket","entry"],"_links":{"self":[{"href":"https:\/\/phonesstillexist.com\/index.php\/wp-json\/wp\/v2\/posts\/154","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/phonesstillexist.com\/index.php\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/phonesstillexist.com\/index.php\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/phonesstillexist.com\/index.php\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/phonesstillexist.com\/index.php\/wp-json\/wp\/v2\/comments?post=154"}],"version-history":[{"count":9,"href":"https:\/\/phonesstillexist.com\/index.php\/wp-json\/wp\/v2\/posts\/154\/revisions"}],"predecessor-version":[{"id":166,"href":"https:\/\/phonesstillexist.com\/index.php\/wp-json\/wp\/v2\/posts\/154\/revisions\/166"}],"wp:attachment":[{"href":"https:\/\/phonesstillexist.com\/index.php\/wp-json\/wp\/v2\/media?parent=154"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/phonesstillexist.com\/index.php\/wp-json\/wp\/v2\/categories?post=154"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/phonesstillexist.com\/index.php\/wp-json\/wp\/v2\/tags?post=154"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}