DEV Community

Building a Local Voice-to-Text Pipeline with Node.js and Whisper

The Architecture

Three stages:

  • Audio ingestion - normalize via FFmpeg
  • Transcription - run Whisper (whisper.cpp) as child process
  • Post-processing - SRT subtitles + SQLite storage
Input -> FFmpeg -> Whisper -> Node.js -> SRT + SQLite

Step 1: Audio Normalization with FFmpeg

Whisper needs 16kHz mono WAV input. Here is the Node.js helper:

const { execa } = require ( ' execa ' );
const path = require ( ' path ' );

async function normalizeAudio ( inputPath , outputDir ) {
  const ext = path . extname ( inputPath );
  const base = path . basename ( inputPath , ext );
  const outputPath = path . join ( outputDir , base + ' _16khz.wav ' );

  await execa ( ' ffmpeg ' , [
    ' -i ' , inputPath ,
    ' -ar ' , ' 16000 ' ,
    ' -ac ' , ' 1 ' ,
    ' -sample_fmt ' , ' s16 ' ,
    ' -y ' , outputPath
  ]);

  return outputPath ;
}

module . exports = { normalizeAudio };

Step 2: Running Whisper from Node.js

We call whisper.cpp CLI as a child process:

const { execa } = require ( ' execa ' );
const path = require ( ' path ' );

const MODEL = ' base ' ; // tiny, base, small, medium, large-v3

async function transcribe ( audioPath ) {
  const outputDir = path . dirname ( audioPath );
  const { stdout } = await execa (
    ' ./whisper.cpp/build/bin/whisper-cli ' ,
    [
      ' -m ' , ' ./whisper.cpp/models/ggml- ' + MODEL + ' .bin ' ,
      ' -f ' , audioPath ,
      ' -otxt ' ,
      ' -osrt ' ,
      ' --output-dir ' , outputDir
    ]
  );

  return {
    text : stdout ,
    srtPath : path . join ( outputDir , path . basename ( audioPath , ' .wav ' ) + ' .srt ' )
  };
}

module . exports = { transcribe };

Step 3: SQLite Storage

Using better-sqlite3 for zero-config persistence:

const Database = require ( ' better-sqlite3 ' );

function initDatabase ( dbPath ) {
  const db = new Database ( dbPath );
  db . exec ([
    ' CREATE TABLE IF NOT EXISTS transcriptions ( ' ,
    '   id INTEGER PRIMARY KEY AUTOINCREMENT, ' ,
    '   filename TEXT NOT NULL, ' ,
    '   duration_seconds REAL, ' ,
    '   model TEXT, ' ,
    '   text TEXT NOT NULL, ' ,
    '   srt_content TEXT, ' ,
    '   created_at DATETIME DEFAULT CURRENT_TIMESTAMP ' ,
    ' ); ' ,
    ' CREATE INDEX IF NOT EXISTS idx_created_at ' ,
    '   ON transcriptions(created_at DESC); '
  ]. join ( ' ' ));
  return db ;
}

function saveTranscription ( db , data ) {
  const stmt = db . prepare ([
    ' INSERT INTO transcriptions ' ,
    ' (filename, duration_seconds, model, text, srt_content) ' ,
    ' VALUES (?, ?, ?, ?, ?) '
  ]. join ( ' ' ));
  return stmt . run (
    data . filename ,
    data . duration ,
    data . model ,
    data . text ,
    data . srtContent
  );
}

function searchTranscriptions ( db , query ) {
  return db . prepare ([
    ' SELECT * FROM transcriptions ' ,
    ' WHERE text LIKE ? ORDER BY created_at DESC '
  ]. join ( ' ' )). all ( ' % ' + query + ' % ' );
}

module . exports = { initDatabase , saveTranscription , searchTranscriptions };

Step 4: The Pipeline Runner

const { normalizeAudio } = require ( ' ./audio/normalize ' );
const { transcribe } = require ( ' ./transcription/whisper ' );
const { initDatabase , saveTranscription } = require ( ' ./storage/db ' );
const fs = require ( ' fs ' );
const path = require ( ' path ' );

async function runPipeline ( inputAudio , dbPath ) {
  dbPath = dbPath || ' ./transcriptions.db ';
  const workDir = ' ./work ';
  fs . mkdirSync ( workDir , { recursive : true });

  console . log ( ' [1/3] Normalizing audio... ' );
  const normalizedPath = await normalizeAudio ( inputAudio , workDir );

  console . log ( ' [2/3] Transcribing with Whisper... ' );
  const result = await transcribe ( normalizedPath );

  console . log ( ' [3/3] Saving to database... ' );
  const db = initDatabase ( dbPath );
  const srtContent = fs . readFileSync ( result . srtPath , ' utf-8 ' );

  saveTranscription ( db , {
    filename : path . basename ( inputAudio ),
    duration : 0 ,
    model : ' whisper-base ' ,
    text : result . text ,
    srtContent : srtContent
  });

  console . log ( ' Done! ' );
  return result ;
}

const audioFile = process . argv [ 2 ];
if ( ! audioFile ) {
  console . error ( ' Usage: node pipeline.js <audio-file> ' );
  process . exit ( 1 );
}

runPipeline ( audioFile ). catch ( console . error );

Performance Benchmarks

Tested on Ryzen 5 5600X with RTX 3060 (12GB VRAM):

Model 1 min audio 10 min audio VRAM WER
tiny 4.2s 38s 1GB 8.1%
base 7.8s 72s 1.5GB 6.7%
small 18.1s 164s 2.8GB 4.3%
medium 42.3s 401s 5.8GB 3.1%
large 89.7s 852s 10.2GB 2.5%

The base model hits the sweet spot: sub-10s for short clips, about 1 minute for a podcast episode, with no noticeable quality loss for English content.

Setup Script

npm init -y
npm install execa better-sqlite3
mkdir -p whisper.cpp/models
wget https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.bin \
    -O whisper.cpp/models/ggml-base.bin
cd whisper.cpp
cmake -B build
cmake --build build --config Release
cd ..
ffmpeg -version || echo "Install FFmpeg first"

Going Further

This pipeline is fully local: no data leaves your network, no API bills, no rate limits. I have been using this for 3 months to transcribe client calls and podcast episodes. The full source with speaker diarization and WebSocket live transcription is on GitHub. Have you built something similar? Share your approach in the comments.

Comments

No comments yet. Start the discussion.