<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: luoyumin</title>
    <description>The latest articles on DEV Community by luoyumin (@luoyumin).</description>
    <link>https://dev.to/luoyumin</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4069874%2F9ecab62c-e8d8-4bfa-9d0c-bdfceca22431.png</url>
      <title>DEV Community: luoyumin</title>
      <link>https://dev.to/luoyumin</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/luoyumin"/>
    <language>en</language>
    <item>
      <title>Stream FreeSWITCH call audio to a WebSocket for real-time ASR (mod_ws_media)</title>
      <dc:creator>luoyumin</dc:creator>
      <pubDate>Sun, 09 Aug 2026 13:09:02 +0000</pubDate>
      <link>https://dev.to/luoyumin/stream-freeswitch-call-audio-to-a-websocket-for-real-time-asr-modwsmedia-32o2</link>
      <guid>https://dev.to/luoyumin/stream-freeswitch-call-audio-to-a-websocket-for-real-time-asr-modwsmedia-32o2</guid>
      <description>&lt;p&gt;If you're building anything that listens to live phone calls — real-time transcription, an AI voice agent, call analytics, compliance — you eventually hit the same wall: &lt;strong&gt;how do you get the audio out of FreeSWITCH, decoded, in real time, and into your ASR/LLM?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;You don't want to deal with RTP, jitter buffers, or per-codec decoding in your app. You want a clean PCM stream over a socket you already know how to consume. That's what I built &lt;strong&gt;mod_ws_media&lt;/strong&gt; for.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it does
&lt;/h2&gt;

&lt;p&gt;mod_ws_media is a FreeSWITCH module that taps a call leg and streams its audio to any WebSocket server in real time. A few things that make it easy to consume:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Decoded L16 PCM&lt;/strong&gt; — your server never touches the RTP codec. Whether the call is PCMU, PCMA, G.722 or Opus, you get plain signed 16-bit PCM at the channel's native rate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One WebSocket per leg.&lt;/strong&gt; On connect the module sends a small JSON &lt;code&gt;start&lt;/code&gt; frame, then streams binary PCM. That's the whole protocol.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Non-intrusive.&lt;/strong&gt; v1 is tap-only (read-only) — it forks the audio out and never modifies the call.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No heavy deps.&lt;/strong&gt; The module ships its own RFC 6455 client on top of OpenSSL — no libwebsockets, no gRPC.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try it in 5 minutes
&lt;/h2&gt;

&lt;p&gt;Build it out-of-tree against an installed FreeSWITCH:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/luoyumin/mod_ws_media
&lt;span class="nb"&gt;cd &lt;/span&gt;mod_ws_media
make
&lt;span class="nb"&gt;sudo &lt;/span&gt;make &lt;span class="nb"&gt;install
&lt;/span&gt;fs_cli &lt;span class="nt"&gt;-x&lt;/span&gt; &lt;span class="s2"&gt;"load mod_ws_media"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The repo ships a &lt;strong&gt;zero-dependency&lt;/strong&gt; example server (pure Python stdlib, no &lt;code&gt;pip install&lt;/code&gt;) so you can see audio flowing without wiring up an ASR backend yet:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python3 example/echo_server.py        &lt;span class="c"&gt;# listens on ws://0.0.0.0:8080/&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then tap a live call — it needs a real media path (&lt;code&gt;echo&lt;/code&gt;, &lt;code&gt;playback&lt;/code&gt;, &lt;code&gt;bridge&lt;/code&gt;, etc., not a parked or bypassed channel):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;uuid_ws_media &amp;lt;uuid&amp;gt; start ws://127.0.0.1:8080/media &lt;span class="k"&gt;in&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;stereo &lt;span class="nv"&gt;role&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;agent
&lt;span class="c"&gt;# ...talk, then hang up...&lt;/span&gt;
&lt;span class="c"&gt;# -&amp;gt; ws_media_recordings/&amp;lt;call_id&amp;gt;.wav&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The example server prints the &lt;code&gt;start&lt;/code&gt; frame and writes the streamed PCM to a playable WAV. Swap it for your own server and you're streaming straight to your ASR.&lt;/p&gt;

&lt;h2&gt;
  
  
  Speaker separation for free
&lt;/h2&gt;

&lt;p&gt;For transcription you almost always want to know &lt;strong&gt;who said what&lt;/strong&gt; — agent vs. customer. mod_ws_media gives you that at the media layer with &lt;code&gt;in=stereo&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;left channel = read&lt;/strong&gt; = this leg's own party (its mic — what it &lt;em&gt;says&lt;/em&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;right channel = write&lt;/strong&gt; = the far party (what this leg &lt;em&gt;hears&lt;/em&gt;)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Tap the agent leg in stereo and you get the agent on the left, the customer on the right — clean diarization without a diarization model. You can also grab a single direction with &lt;code&gt;in=read&lt;/code&gt; / &lt;code&gt;in=write&lt;/code&gt;, or a summed mono mix with &lt;code&gt;in=mixed&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The wire protocol
&lt;/h2&gt;

&lt;p&gt;On connect, one JSON text frame:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"event"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"start"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"call_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"abc123"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"media_format"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"encoding"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"L16"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"sample_rate"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;8000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"channels"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"ptime"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"capture"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"mode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"stereo"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"tracks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"ch"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"read"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nl"&gt;"role"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"agent"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"ch"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"write"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"role"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"peer"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then binary frames: raw signed 16-bit little-endian PCM. For &lt;code&gt;stereo&lt;/code&gt;, samples are interleaved &lt;code&gt;[L R L R …]&lt;/code&gt;. Treat it as a byte stream — a binary message isn't guaranteed to be exactly one 20 ms frame.&lt;/p&gt;

&lt;p&gt;Config is per-call (URL, capture mode, role, call_id, metadata) via command args or channel variables, so different calls can stream to different backends.&lt;/p&gt;

&lt;h2&gt;
  
  
  When the backend hiccups
&lt;/h2&gt;

&lt;p&gt;Long-lived taps need to survive backend restarts. The module reconnects automatically, and if the server stays unreachable it drops into a recoverable &lt;strong&gt;bypass&lt;/strong&gt; mode — the call continues untouched, and the tap resumes when the backend comes back. TLS (&lt;code&gt;wss://&lt;/code&gt;) with optional certificate verification and SNI is supported too.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scope and what's next
&lt;/h2&gt;

&lt;p&gt;v1.0.x is &lt;strong&gt;tap only&lt;/strong&gt; — capture audio out. That already covers real-time transcription, recording and analytics, where you push results to a screen or a pipeline rather than back into the call.&lt;/p&gt;

&lt;p&gt;On the roadmap is &lt;strong&gt;injection&lt;/strong&gt; — putting processed audio back into a chosen leg. That's what you need for real-time translation, prompts, or agent whisper. The design is written up in &lt;code&gt;docs/DESIGN.md&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Repo:&lt;/strong&gt; &lt;a href="https://github.com/luoyumin/mod_ws_media" rel="noopener noreferrer"&gt;https://github.com/luoyumin/mod_ws_media&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;License:&lt;/strong&gt; MPL 1.1 (same as FreeSWITCH)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're streaming FreeSWITCH audio to an LLM or ASR and hit rough edges, open an issue — I'd like to hear what you're building.&lt;/p&gt;

</description>
      <category>freeswitch</category>
      <category>webrtc</category>
      <category>ai</category>
      <category>c</category>
    </item>
  </channel>
</rss>
