All posts
9 Sep 2026

Real-Time Audio/Video Transcription in Node.js: Streaming Chunked Media with FFmpeg and WebSockets

A practical, code-heavy architectural guide on capturing live audio and video streams, piping them through child_process FFmpeg for format normalization, and streaming chunks over WebSockets in Node.js for real-time transcription.

Real-Time Audio/Video Transcription in Node.js: Streaming Chunked Media with FFmpeg and WebSockets

Building real-time media ingestion and processing pipelines is notoriously challenging. Whether you are building a live-captioning service, an AI meeting assistant, or a remote telemetry monitor, you must handle messy client-side inputs, normalize arbitrary codecs, and pipe streaming binary data across the network with minimal latency.

In this architectural guide, we will build a complete, production-ready backend pipeline using Node.js, FFmpeg (via child_process), and WebSockets. We will capture raw audio/video streams from a client browser, normalize them into a predictable container format on the fly, stream them over a persistent WebSocket connection, and process them for downstream transcription.


High-Level Architecture

Before diving into the code, let’s map out how data flows through our system:

  1. Client Capture: The browser uses the MediaRecorder API or WebRTC to capture raw mic/camera streams.
  2. WebSocket Ingestion: The client streams binary blobs over a WebSocket connection to our Node.js backend.
  3. FFmpeg Normalization: Node.js receives the chunks and pipes them into a spawned ffmpeg child process. FFmpeg transcodes and standardizes the stream into a continuous, low-latency container (such as fragmented WebM or PCM audio).
  4. Downstream Transcription: The normalized chunks are piped out of FFmpeg’s stdout and dispatched to an ASR (Automatic Speech Recognition) engine or transcription service.
code
+----------------+       WebSocket (Binary)       +-------------------------+
|                | -----------------------------> |                         |
|     Client     |                                |      Node.js Server     |
|  (Browser/App) | <----------------------------- |                         |
+----------------+       JSON/Transcription       +-------------------------+
                                                               |
                                                               | Pipe Stdin/Stdout
                                                               V
                                                  +-------------------------+
                                                  |      FFmpeg Process     |
                                                  |  (Normalize & Transcode)|
                                                  +-------------------------+

Prerequisites & Environment Setup

To follow along, make sure you have the following installed on your system:

  • Node.js (v18+ recommended)
  • FFmpeg accessible via your system’s PATH (verify with ffmpeg -version)

Initialize a new Node.js project and install the required dependencies:

mkdir rt-transcription-server
cd rt-transcription-server
npm init -y
npm install ws express
npm install --save-dev nodemon

Step 1: The Express & WebSocket Server Backbone

We will use express to serve a simple test client and ws to manage our WebSocket server. Create a file named server.js:

const express = require('express');
const { createServer } = require('http');
const { WebSocketServer } = require('ws');
const path = require('path');
const { spawnFFmpegPipeline } = require('./ffmpegPipeline');

const app = express();
const server = createServer(app);
const wss = new WebSocketServer({ server });

app.use(express.static(path.join(__dirname, 'public')));

wss.on('connection', (ws) => {
  console.log('[WebSocket] Client connected');

  // Initialize the FFmpeg processing pipeline for this connection
  const ffmpegProc = spawnFFmpegPipeline((transcriptionChunk) => {
    if (ws.readyState === ws.OPEN) {
      ws.send(JSON.stringify({ type: 'transcript', data: transcriptionChunk }));
    }
  });

  ws.on('message', (message, isBinary) => {
    if (isBinary) {
      // Pipe incoming binary audio/video chunks directly into FFmpeg's stdin
      if (!ffmpegProc.stdin.destroyed) {
        ffmpegProc.stdin.write(message);
      }
    } else {
      try {
        const payload = JSON.parse(message);
        if (payload.event === 'stop') {
          console.log('[WebSocket] Stop signal received');
          ffmpegProc.stdin.end();
        }
      } catch (err) {
        console.error('Failed to parse incoming JSON message:', err);
      }
    }
  });

  ws.on('close', () => {
    console.log('[WebSocket] Client disconnected');
    if (!ffmpegProc.killed) {
      ffmpegProc.kill('SIGKILL');
    }
  });
});

const PORT = process.env.PORT || 3000;
server.listen(PORT, () => {
  console.log(`Server running on http://localhost:${PORT}`);
});

Step 2: Orchestrating FFmpeg with Node.js child_process

The core of our normalization layer lies in spawning an FFmpeg process. We will accept arbitrary incoming web chunks, pipe them to FFmpeg’s stdin, force a standard output format (e.g., 16kHz mono PCM for speech-to-text models), and read from stdout.

Create ffmpegPipeline.js:

const { spawn } = require('child_process');

/**
 * Spawns an FFmpeg process to transcode incoming media chunks into raw PCM audio
 * suitable for speech-to-text engines.
 * 
 * @param {Function} onDataCallback - Callback triggered when processed data is available
 * @returns {ChildProcess}
 */
function spawnFFmpegPipeline(onDataCallback) {
  // Arguments explained:
  // -fflags nobuffer: Reduce latency by disabling buffering
  // -i pipe:0: Read input from stdin
  // -f s16le: Output raw 16-bit little-endian PCM
  // -acodec pcm_s16le: PCM audio codec
  // -ac 1: Mono audio channel
  // -ar 16000: 16kHz sampling rate (ideal for Whisper, Vosk, Google STT)
  // -vn: Drop video streams if we only want audio transcription
  // pipe:1: Write output to stdout
  const args = [
    '-fflags', 'nobuffer',
    '-i', 'pipe:0',
    '-f', 's16le',
    '-acodec', 'pcm_s16le',
    '-ac', '1',
    '-ar', '16000',
    '-vn',
    'pipe:1'
  ];

  const ffmpeg = spawn('ffmpeg', args);

  ffmpeg.stdout.on('data', (chunk) => {
    // Here 'chunk' is a Buffer containing raw 16kHz PCM audio.
    // In a production app, you would pipe this Buffer to your STT service (e.g., OpenAI Whisper API, Deepgram, Vosk).
    
    // For demonstration, we simulate transcription output based on incoming data volume:
    onDataCallback(`[Transcribing...] Received ${chunk.length} bytes of normalized PCM audio.`);
  });

  ffmpeg.stderr.on('data', (data) => {
    // FFmpeg logs progress to stderr. Filter out routine info if needed.
    const logMessage = data.toString();
    if (logMessageincludes('error') || logMessage.includes('Error')) {
      console.error(`[FFmpeg Error]: ${logMessage}`);
    }
  });

  ffmpeg.on('close', (code) => {
    console.log(`[FFmpeg] Process exited with code ${code}`);
  });

  return ffmpeg;
}

export.spawnFFmpegPipeline = spawnFFmpegPipeline;

Architectural Note: By piping pipe:0 (stdin) and reading pipe:1 (stdout), we avoid writing any temporary files to disk. This keeps memory footprints low and prevents I/O bottlenecks when scaling across thousands of concurrent sessions.


Step 3: Building the Client-Side Capture Interface

To test our pipeline, let’s create a minimal frontend interface that captures audio using the browser’s MediaRecorder API and streams blobs over WebSockets.

Create a public/index.html file:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Real-Time Transcription Test</title>
  <style>
    body { font-family: Arial, sans-serif; max-width: 600px; margin: 40px auto; padding: 20px; }
    button { padding: 10px 20px; font-size: 16px; cursor: pointer; margin-right: 10px; }
    #log { background: #f4f4f4; padding: 15px; height: 300px; overflow-y: scroll; border: 1px solid #ddd; margin-top: 20px; }
  </style>
</head>
<body>

  <h2>Live Audio Transcription Stream</h2>
  <div>
    <button id="startBtn">Start Recording</button>
    <button id="stopBtn" disabled>Stop Recording</button>
  </div>

  <div id="log"></div>

  <script>
    let ws;
    let mediaRecorder;
    const startBtn = document.getElementById('startBtn');
    const stopBtn = document.getElementById('stopBtn');
    const logDiv = document.getElementById('log');

    function appendLog(message) {
      const p = document.createElement('p');
      p.textContent = message;
      logDiv.appendChild(p);
      logDiv.scrollTop = logDiv.scrollHeight;
    }

    startBtn.onclick = async () => {
      try {
        const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false });
        
        ws = new WebSocket(`ws://${window.location.host}`);
        ws.binaryType = 'arraybuffer';

        ws.onopen = () => {
          appendLog('[WebSocket] Connected to server');
          
          // Configure MediaRecorder to fire data every 250ms for low-latency streaming
          mediaRecorder = new MediaRecorder(stream, { mimeType: 'audio/webm' });
          
          mediaRecorder.ondataavailable = (event) => {
            if (event.data.size > 0 && ws.readyState === WebSocket.OPEN) {
              ws.send(event.data);
            }
          };

          mediaRecorder.start(250);
          startBtn.disabled = true;
          stopBtn.disabled = false;
          appendLog('[MediaRecorder] Recording started...');
        };

        ws.onmessage = (event) => {
          const response = JSON.parse(event.data);
          appendLog(response.data);
        };

        ws.onclose = () => {
          appendLog('[WebSocket] Connection closed');
        };

      } catch (err) {
        console.error(err);
        appendLog('[Error] ' + err.message);
      }
    };

    stopBtn.onclick = () => {
      if (mediaRecorder) {
        mediaRecorder.stop();
        mediaRecorder.stream.getTracks().forEach(track => track.stop());
      }
      if (ws) {
        ws.send(JSON.stringify({ event: 'stop' }));
        ws.close();
      }
      startBtn.disabled = false;
      stopBtn.disabled = true;
      appendLog('[MediaRecorder] Recording stopped.');
    };
  </script>
</body>
</html>

Step 4: Connecting to Real Transcription Engines (Deep Dive)

In our ffmpegPipeline.js example, we simulated transcription output when receiving buffers from stdout. In a production environment, you would bridge that stdout stream to an ASR provider. Here is how you can integrate with common transcription pipelines:

Option A: Streaming to Local Whisper (Python Subprocess or HTTP)

If you are running a local model like whisper.cpp or OpenAI’s Whisper via Python, you can spawn a secondary child process or establish a gRPC/HTTP/2 stream directly from Node.js.

const { spawn } = require('child_process');

// Example: Piping normalized PCM directly into a Python Whisper worker
function attachWhisperWorker(ffmpegStdout) {
  const pythonWhisper = spawn('python3', ['whisper_worker.py']);

  ffmpegStdout.pipe(pythonWhisper.stdin);

  pythonWhisper.stdout.on('data', (data) => {
    console.log(`Transcript segment: ${data.toString()}`);
  });
}

Option B: WebSocket-based Cloud STT APIs (e.g., Deepgram, AssemblyAI)

Many modern STT providers accept live WebSocket audio streams. Instead of routing through FFmpeg locally, you can use FFmpeg to normalize the stream and pipe it directly into the vendor’s WebSocket client socket.


Production Hardening and Scaling Considerations

When deploying a real-time transcription architecture to production, keep these operational challenges in mind:

  1. Backpressure Handling: Node.js streams implement backpressure automatically. However, if your downstream transcription service consumes data slower than FFmpeg produces it, FFmpeg’s stdout buffer can fill up. Always monitor stream.write() return values and handle drain events.
  2. Resource Management: Spawning an FFmpeg process per concurrent WebSocket connection consumes CPU and RAM. Ensure your cluster nodes are autoscaling based on CPU utilization, and implement strict timeout limits for idle connections.
  3. Zombie Process Prevention: If a client abruptly drops their WebSocket connection without sending a close frame, the server must intercept the TCP disconnect event and forcefully kill the associated FFmpeg PID (ffmpegProc.kill('SIGKILL')) to prevent orphaned processes from exhausting server resources.
  4. Cluster Scaling with Redis: If you scale your Node.js backend horizontally across multiple containers behind a load balancer, standard WebSockets require sticky sessions. For robust scaling, consider routing WebSocket ingress through a message broker like Redis Pub/Sub or switching to dedicated media servers like Mediasoup or Janus.

Conclusion

By combining Node.js Streams, FFmpeg child processes, and WebSockets, you can build a robust, high-performance ingestion pipeline capable of normalizing and transcribing live audio and video streams in real time. Because this architecture avoids disk I/O and standardizes fragmented codecs on the fly, it serves as a reliable foundation for enterprise-grade AI applications, live captioning engines, and real-time audio analytics.

More posts