← All Tutorials

Auto QA Call Recording Quality with NISQA & Silero VAD

Monitoring & Observability Intermediate 14 min read #100 Published

Detect poor call audio in seconds by measuring speech intelligibility and voice activity during ViciDial recording post-processing.

Call recording quality problems kill agent training data, compliance audits, and customer experience analysis. NISQA (Non-Intrusive Speech Quality Assessment) scores audio intelligibility on a 1-5 MOS (Mean Opinion Score) scale without needing a reference recording. Silero VAD (Voice Activity Detection) identifies silence, background noise, and clipping. Run both tools against recordings in your ViciDial archive immediately after the call ends. Flag low-quality files, trigger re-recording for critical calls, and alert supervisors to codec or network issues before they compound.

This guide walks through installation, integration with ViciDial's recording pipeline, real scoring workflows, and troubleshooting. Tested on ViciDial 2.14 (SVN 3555+), Asterisk 16/18, and NISQA 1.0+.

Prerequisites

You need a working ViciDial system with:

Optional but recommended:

Why recording quality breaks in ViciDial

Call audio quality depends on codec selection, RTP timing, jitter buffers, and network path. A SIP call routed through a poor WAN link with G.729 compression may score 2.0 on NISQA even though the call connected. Agents report audio as "choppy" or "robotic." Without automated scoring, quality issues hide in the archive until QA staff manually spot them.

NISQA and Silero detect these problems programmatically:

Together they flag calls where the agent or customer cannot be understood, or where background noise drowns out speech.

Install NISQA and Silero VAD

Download and configure NISQA

cd /opt
git clone https://github.com/gabrielmittag/NISQA.git
cd NISQA
python3 -m venv venv
source venv/bin/activate
pip install --upgrade pip
pip install torch torchaudio librosa numpy scipy matplotlib
pip install -e .

NISQA downloads a pre-trained model (~200MB) on first run. Test the installation:

cd /opt/NISQA
python3 -c "from nisqa.NISQA_model import NISQAModel; m = NISQAModel(pretrained_model_dir='nisqa_pretrained_model'); print('NISQA ready')"

Model download runs in the background. On first execution, wait 2-3 minutes.

Install Silero VAD

pip install silero-vad
python3 -c "import torch; torch.hub.load('snakers4/silero-vad', 'get_speech_timestamps', trust_repo=True); print('Silero VAD ready')"

Silero downloads a ~40MB ONNX model the first time you call it.

Verify codec support

NISQA and Silero work on raw audio streams. If your ViciDial recordings are compressed (G.729, GSM), decode them first:

apt-get install -y ffmpeg
ffmpeg -i call_20250115_120000_5551234567.wav -acodec pcm_s16le -ar 16000 decoded.wav

Or decode in-process in your scoring script (covered below).

Design the QA scoring workflow

Your workflow is:

  1. Call ends, asterisk writes .wav or .mp3 to /var/spool/asterisk/monitor/
  2. ViciDial cron job moves or copies file to archive
  3. Your QA service picks up the file, decodes, runs NISQA + Silero VAD
  4. Scores are inserted into a ViciDial database table
  5. QA dashboard or agent screen shows results

Create a new table in the asterisk database to hold QA results:

CREATE TABLE vicidial_qa_scores (
  qa_id INT AUTO_INCREMENT PRIMARY KEY,
  call_date DATETIME DEFAULT CURRENT_TIMESTAMP,
  vicidial_log_id BIGINT NOT NULL,
  filename VARCHAR(255) NOT NULL,
  nisqa_score FLOAT,
  nisqa_raw FLOAT,
  nisqa_dim_col FLOAT,
  nisqa_dim_noi FLOAT,
  nisqa_dim_mos FLOAT,
  speech_percentage FLOAT,
  silence_duration_sec FLOAT,
  noise_floor_db FLOAT,
  clipping_detected BOOLEAN DEFAULT 0,
  processing_status ENUM('pending', 'processed', 'error') DEFAULT 'pending',
  error_message TEXT,
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  UNIQUE KEY unique_file (filename),
  KEY idx_vicidial_log_id (vicidial_log_id),
  KEY idx_processing_status (processing_status)
);

This table links to ViciDial's vicidial_log table via vicidial_log_id. The scoring service queries for recordings not yet scored, processes them, and updates this table.

Create the QA scoring service

Here is a production-grade Python script that monitors your recording directory, scores new files, and stores results:

#!/usr/bin/env python3
"""
ViciDial Call Recording QA Scorer using NISQA and Silero VAD
Monitors recording directory, scores audio, updates database
"""

import os
import sys
import argparse
import logging
import json
import subprocess
from pathlib import Path
from datetime import datetime
import hashlib

import MySQLdb
import torch
import torchaudio
import librosa
import numpy as np
from nisqa.NISQA_model import NISQAModel

logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s - %(levelname)s - %(message)s',
    handlers=[
        logging.FileHandler('/var/log/asterisk/qa_scorer.log'),
        logging.StreamHandler()
    ]
)
logger = logging.getLogger(__name__)


class QAScorer:
    def __init__(self, db_host='localhost', db_user='asteriskuser',
                 db_pass='asterisk', db_name='asterisk',
                 recordings_dir='/var/spool/asterisk/monitor'):
        self.db_host = db_host
        self.db_user = db_user
        self.db_pass = db_pass
        self.db_name = db_name
        self.recordings_dir = Path(recordings_dir)
        
        # Initialize NISQA model
        logger.info("Loading NISQA model...")
        self.nisqa_model = NISQAModel(
            pretrained_model_dir='/opt/NISQA/nisqa_pretrained_model'
        )
        
        # Load Silero VAD
        logger.info("Loading Silero VAD...")
        self.silero_vad = torch.hub.load(
            'snakers4/silero-vad',
            'get_speech_timestamps',
            trust_repo=True
        )
        self.silero_get_speech_ts = torch.hub.load(
            'snakers4/silero-vad',
            'get_speech_timestamps',
            trust_repo=True
        )
        
        self.db_conn = None
        self.connect_db()
    
    def connect_db(self):
        try:
            self.db_conn = MySQLdb.connect(
                host=self.db_host,
                user=self.db_user,
                passwd=self.db_pass,
                db=self.db_name
            )
            logger.info("Database connected")
        except MySQLdb.Error as e:
            logger.error(f"Database connection failed: {e}")
            sys.exit(1)
    
    def decode_audio(self, filepath, target_sr=16000):
        """Decode any audio format to 16kHz mono PCM"""
        if filepath.suffix.lower() == '.wav':
            try:
                waveform, sr = torchaudio.load(filepath)
                if sr != target_sr:
                    resampler = torchaudio.transforms.Resample(sr, target_sr)
                    waveform = resampler(waveform)
                if waveform.shape[0] > 1:
                    waveform = waveform.mean(dim=0, keepdim=True)
                return waveform.squeeze().numpy(), target_sr
            except Exception as e:
                logger.error(f"Decode error {filepath}: {e}")
                return None, None
        else:
            # Use ffmpeg for other formats
            try:
                temp_wav = f"/tmp/{filepath.stem}_temp.wav"
                cmd = [
                    'ffmpeg', '-i', str(filepath),
                    '-ar', str(target_sr), '-ac', '1',
                    '-f', 'wav', temp_wav, '-y'
                ]
                subprocess.run(cmd, check=True, capture_output=True, timeout=30)
                waveform, sr = torchaudio.load(temp_wav)
                os.remove(temp_wav)
                return waveform.squeeze().numpy(), sr
            except Exception as e:
                logger.error(f"FFmpeg decode error {filepath}: {e}")
                return None, None
    
    def score_nisqa(self, waveform, sr):
        """Score audio with NISQA"""
        try:
            # NISQA expects dict input with 'speech' key
            result = self.nisqa_model.predict(
                torch.from_numpy(waveform).float().unsqueeze(0),
                sr=sr
            )
            return {
                'mos_score': float(result[0]['mos']),
                'mos_raw': float(result[0]['mos_raw']),
                'dim_col': float(result[0]['dim_col']),
                'dim_noi': float(result[0]['dim_noi']),
                'dim_mos': float(result[0]['dim_mos'])
            }
        except Exception as e:
            logger.error(f"NISQA scoring error: {e}")
            return None
    
    def score_vad(self, waveform, sr):
        """Score voice activity with Silero VAD"""
        try:
            # Silero VAD expects float32 tensor
            wav_tensor = torch.from_numpy(waveform).float()
            if wav_tensor.dim() == 1:
                wav_tensor = wav_tensor.unsqueeze(0)
            
            # Get speech timestamps (list of dicts with 'start' and 'end' in samples)
            speech_ts = self.silero_get_speech_ts(wav_tensor, sr)
            
            if not speech_ts:
                return {
                    'speech_percentage': 0.0,
                    'silence_duration_sec': len(waveform) / sr,
                    'num_speech_segments': 0,
                    'clipping_detected': False,
                    'noise_floor_db': -80.0
                }
            
            total_speech_samples = sum(
                ts['end'] - ts['start'] for ts in speech_ts
            )
            speech_percentage = (total_speech_samples / len(waveform)) * 100.0
            silence_duration = (len(waveform) - total_speech_samples) / sr
            
            # Detect clipping (samples at near max amplitude)
            max_val = np.max(np.abs(waveform))
            clipping_threshold = 0.99
            clipping_detected = max_val > clipping_threshold
            
            # Estimate noise floor from silent segments
            if len(speech_ts) > 0:
                silent_regions = []
                if speech_ts[0]['start'] > 0:
                    silent_regions.append(waveform[:speech_ts[0]['start']])
                for i in range(len(speech_ts) - 1):
                    gap = waveform[speech_ts[i]['end']:speech_ts[i+1]['start']]
                    if len(gap) > sr * 0.05:  # Gaps larger than 50ms
                        silent_regions.append(gap)
                
                if silent_regions:
                    noise = np.concatenate(silent_regions)
                    rms = np.sqrt(np.mean(noise ** 2))
                    noise_db = 20 * np.log10(rms + 1e-10)
                else:
                    noise_db = -80.0
            else:
                noise_db = -80.0
            
            return {
                'speech_percentage': float(speech_percentage),
                'silence_duration_sec': float(silence_duration),
                'num_speech_segments': len(speech_ts),
                'clipping_detected': bool(clipping_detected),
                'noise_floor_db': float(noise_db)
            }
        except Exception as e:
            logger.error(f"VAD scoring error: {e}")
            return None
    
    def get_vicidial_log_id(self, filename):
        """Extract or look up vicidial_log_id from filename or database"""
        # ViciDial filenames are typically YYYYMMDD-HHMMSS_UNIQUEID.wav
        # Try to find in vicidial_log by matching filename
        try:
            cursor = self.db_conn.cursor()
            cursor.execute("""
                SELECT vicidial_log_id FROM vicidial_log
                WHERE recording LIKE CONCAT('%', %s, '%')
                ORDER BY call_date DESC LIMIT 1
            """, (filename,))
            row = cursor.fetchone()
            cursor.close()
            return row[0] if row else None
        except MySQLdb.Error as e:
            logger.error(f"Query error: {e}")
            return None
    
    def process_file(self, filepath):
        """Score a single recording file"""
        filename = filepath.name
        
        # Check if already processed
        try:
            cursor = self.db_conn.cursor()
            cursor.execute(
                "SELECT qa_id FROM vicidial_qa_scores WHERE filename = %s",
                (filename,)
            )
            if cursor.fetchone():
                logger.info(f"Skipping {filename} (already scored)")
                cursor.close()
                return
            cursor.close()
        except MySQLdb.Error as e:
            logger.error(f"Lookup error: {e}")
            return
        
        logger.info(f"Processing {filename}")
        
        # Decode audio
        waveform, sr = self.decode_audio(filepath)
        if waveform is None:
            self.insert_qa_result(
                filename, None,
                processing_status='error',
                error_message='Audio decode failed'
            )
            return
        
        # Run NISQA
        nisqa_result = self.score_nisqa(waveform, sr)
        if nisqa_result is None:
            self.insert_qa_result(
                filename, None,
                processing_status='error',
                error_message='NISQA scoring failed'
            )
            return
        
        # Run Silero VAD
        vad_result = self.score_vad(waveform, sr)
        if vad_result is None:
            self.insert_qa_result(
                filename, nisqa_result,
                processing_status='error',
                error_message='VAD scoring failed'
            )
            return
        
        # Insert results
        self.insert_qa_result(
            filename, nisqa_result, vad_result,
            processing_status='processed'
        )
        logger.info(f"Scored {filename}: NISQA={nisqa_result['mos_score']:.2f}, Speech={vad_result['speech_percentage']:.1f}%")
    
    def insert_qa_result(self, filename, nisqa_result=None, vad_result=None,
                        processing_status='pending', error_message=None):
        """Insert QA scores into database"""
        try:
            vicidial_log_id = self.get_vicidial_log_id(filename)
            
            cursor = self.db_conn.cursor()
            cursor.execute("""
                INSERT INTO vicidial_qa_scores (
                    filename, vicidial_log_id,
                    nisqa_score, nisqa_raw, nisqa_dim_col, nisqa_dim_noi, nisqa_dim_mos,
                    speech_percentage, silence_duration_sec, clipping_detected,
                    processing_status, error_message
                ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
            """, (
                filename,
                vicidial_log_id,
                nisqa_result['mos_score'] if nisqa_result else None,
                nisqa_result['mos_raw'] if nisqa_result else None,
                nisqa_result['dim_col'] if nisqa_result else None,
                nisqa_result['dim_noi'] if nisqa_result else None,
                nisqa_result['dim_mos'] if nisqa_result else None,
                vad_result['speech_percentage'] if vad_result else None,
                vad_result['silence_duration_sec'] if vad_result else None,
                vad_result['clipping_detected'] if vad_result else 0,
                processing_status,
                error_message
            ))
            self.db_conn.commit()
            cursor.close()
        except MySQLdb.Error as e:
            logger.error(f"Insert error: {e}")
            self.db_conn.rollback()
    
    def scan_and_process(self, age_hours=24):
        """Scan recording directory and process unscored files"""
        if not self.recordings_dir.exists():
            logger.error(f"Recordings dir not found: {self.recordings_dir}")
            return
        
        cutoff_time = datetime.now().timestamp() - (age_hours * 3600)
        wav_files = list(self.recordings_dir.glob('*.wav')) + \
                    list(self.recordings_dir.glob('*.mp3'))
        
        logger.info(f"Found {len(wav_files)} audio files")
        
        for filepath in sorted(wav_files):
            if filepath.stat().st_mtime < cutoff_time:
                continue
            self.process_file(filepath)
    
    def close(self):
        if self.db_conn:
            self.db_conn.close()


def main():
    parser = argparse.ArgumentParser(
        description='ViciDial QA Call Recording Scorer'
    )
    parser.add_argument('--db-host', default='localhost')
    parser.add_argument('--db-user', default='asteriskuser')
    parser.add_argument('--db-pass', default='asterisk')
    parser.add_argument('--recordings-dir', default='/var/spool/asterisk/monitor')
    parser.add_argument('--age-hours', type=int, default=24,
                       help='Only process files younger than this')
    
    args = parser.parse_args()
    
    scorer = QAScorer(
        db_host=args.db_host,
        db_user=args.db_user,
        db_pass=args.db_pass,
        recordings_dir=args.recordings_dir
    )
    
    try:
        scorer.scan_and_process(age_hours=args.age_hours)
    finally:
        scorer.close()
    
    logger.info("QA scoring completed")


if __name__ == '__main__':
    main()

Save this script to /opt/vicidial_qa_scorer.py and make it executable:

chmod +x /opt/vicidial_qa_scorer.py
pip install MySQLdb

Schedule automated scoring with systemd

Create a systemd timer to run scoring every 5 minutes:

# /etc/systemd/system/vicidial-qa-scorer.service
[Unit]
Description=ViciDial QA Call Recording Scorer
After=asterisk.service mysql.service

[Service]
Type=oneshot
User=root
ExecStart=/usr/bin/python3 /opt/vicidial_qa_scorer.py \
  --db-host localhost \
  --db-user asteriskuser \
  --db-pass asterisk \
  --recordings-dir /var/spool/asterisk/monitor \
  --age-hours 12
StandardOutput=journal
StandardError=journal

[Install]
WantedBy=multi-user.target
# /etc/systemd/system/vicidial-qa-scorer.timer
[Unit]
Description=ViciDial QA Scorer Timer
Requires=vicidial-qa-scorer.service

[Timer]
OnBootSec=2min
OnUnitActiveSec=5min
AccuracySec=1min

[Install]
WantedBy=timers.target

Enable and start the timer:

systemctl daemon-reload
systemctl enable vicidial-qa-scorer.timer
systemctl start vicidial-qa-scorer.timer
systemctl status vicidial-qa-scorer.timer

View and alert on QA scores

Query your scores to find poor-quality calls:

-- Find calls with low NISQA scores
SELECT filename, nisqa_score, speech_percentage, clipping_detected
FROM vicidial_qa_scores
WHERE nisqa_score < 2.5 AND processing_status = 'processed'
ORDER BY call_date DESC LIMIT 20;

-- Find calls with excessive silence
SELECT filename, speech_percentage, silence_duration_sec
FROM vicidial_qa_scores
WHERE speech_percentage < 30 AND processing_status = 'processed'
ORDER BY call_date DESC LIMIT 20;

-- Find calls with clipping
SELECT filename, nisqa_score, clipping_detected
FROM vicidial_qa_scores
WHERE clipping_detected = 1 AND processing_status = 'processed'
ORDER BY call_date DESC LIMIT 20;

Create an alert script that emails supervisors when quality drops:

#!/bin/bash
# /opt/qa_alert.sh - Run hourly via cron

THRESHOLD_MOS=2.5
THRESHOLD_SPEECH=40.0

MYSQL_PASS="asterisk"
MYSQL_USER="asteriskuser"
MYSQL_HOST="localhost"
MYSQL_DB="asterisk"

QUERY="SELECT COUNT(*) FROM vicidial_qa_scores
WHERE processing_status = 'processed'
AND call_date > NOW() - INTERVAL 1 HOUR
AND (nisqa_score < $THRESHOLD_MOS OR speech_percentage < $THRESHOLD_SPEECH);"

COUNT=$(mysql -h $MYSQL_HOST -u $MYSQL_USER -p$MYSQL_PASS $MYSQL_DB -se "$QUERY")

if [ "$COUNT" -gt 0 ]; then
    echo "QA Alert: $COUNT calls with poor quality in the last hour" | \
      mail -s "ViciDial QA Alert" [email protected]
fi
# Add to /etc/cron.d/vicidial-qa
0 * * * * root /opt/qa_alert.sh >> /var/log/asterisk/qa_alert.log 2>&1

Interpret NISQA scores

NISQA outputs a Mean Opinion Score (MOS) from 1.0 to 5.0:

Silero VAD reports speech percentage (0-100%). Below 40% indicates a call that is mostly silent or heavily noise-gated. Above 70% with a high NISQA score is ideal.

Clipping (amplitude near 1.0 for multiple consecutive samples) indicates gain-staging problems. Reduce input gain on the SIP trunk or agent headset.

Why NISQA scores vary by codec

ViciDial typically uses:

If your agents complain about "robot voices," check your trunk codec settings in /etc/asterisk/sip-vicidial.conf:

[carrier_name]
type=peer
codec=ulaw,alaw,g729
disallow=all
allow=ulaw
allow=alaw
allow=g729

Switch preferred codec order by listing best-quality first:

allow=ulaw
allow=alaw
allow=g729

Or force one codec:

allow=ulaw
disallow=all

Reload the config:

asterisk -rx "sip reload"

Run QA scoring again on the next batch of calls. NISQA scores should improve.

Handle special cases

Multi-channel recordings

Some ViciDial setups record agent and customer on separate channels, then mix them. If your wav file is stereo, decode to mono before scoring:

waveform, sr = torchaudio.load(filepath)
if waveform.shape[0] > 1:
    waveform = waveform.mean(dim=0, keepdim=True)  # Mix to mono

The Python script above already does this.

Very short calls

Calls under 3 seconds confuse both NISQA and Silero. Add a check:

if len(waveform) / sr < 3.0:
    logger.info(f"Skipping {filename}: too short")
    self.insert_qa_result(filename, processing_status='skipped',
                         error_message='Call duration < 3 seconds')
    return

Noisy environments

Call centers with open floor plans have high background noise. Silero VAD will report low speech percentage even if the call is successful. Compare your NISQA dim_noi (noise dimension) score with speech percentage. High dim_noi and low speech percentage indicate environmental noise, not transmission loss.

Troubleshooting

NISQA model download hangs

The model (200MB) downloads on first inference. If your server has slow or metered internet, pre-download:

cd /opt/NISQA
python3 << 'EOF'
from nisqa.NISQA_model import NISQAModel
print("Downloading model...")
m = NISQAModel(pretrained_model_dir='nisqa_pretrained_model')
print("Done. Model at nisqa_pretrained_model/")
EOF

This can take 5-10 minutes on slow connections. Run it interactively so you see progress.

"CUDA out of memory" when scoring

NISQA and Silero VAD load the model into GPU memory. If you have a low-VRAM GPU or many concurrent scores, move models to CPU:

# In QAScorer.__init__:
self.nisqa_model = NISQAModel(
    pretrained_model_dir='/opt/NISQA/nisqa_pretrained_model',
    device='cpu'  # Force CPU
)

Scoring will be slower but will not crash.

Silero VAD failing with "AssertionError: Assert sr == 16000"

Silero VAD is strict about sample rate. Ensure your decode function resamples to 16kHz:

if sr != 16000:
    resampler = torchaudio.transforms.Resample(sr, 16000)
    waveform = resampler(waveform)
    sr = 16000

MySQL error "Column 'noise_floor_db' doesn't exist"

Your vicidial_qa_scores table is missing the optional column. Add it:

ALTER TABLE vicidial_qa_scores ADD COLUMN noise_floor_db FLOAT DEFAULT NULL;

Scoring takes 30+ seconds per file

NISQA inference on CPU takes 15-30 seconds per file. If you have thousands of backlog files, run multiple scorer instances in parallel with different time windows:

# Scorer 1: files from 0-6 hours ago
python3 /opt/vicidial_qa_scorer.py --age-hours 6 &

# Scorer 2: files from 6-12 hours ago (modify script to support age range)
python3 /opt/vicidial_qa_scorer.py --age-hours 12 &

wait

Or schedule with staggered cron entries at 5-minute intervals.

ffmpeg errors on .mp3 files

If ffmpeg fails on .mp3, install the codec:

apt-get install -y libavcodec-extra

Or upgrade ffmpeg:

apt-get install -y ffmpeg
ffmpeg -version  # Should be 4.2+

Scores not appearing in database

Check the service log:

journalctl -u vicidial-qa-scorer.service -n 50 -e
tail -50 /var/log/asterisk/qa_scorer.log

Common issues:

Fix permissions:

chmod 755 /var/spool/asterisk/monitor

Asterisk not recording

Call recordings are disabled if recording is not set in dialplan or extensions-vicidial.conf. Check that MixMonitor is being called:

grep -n "MixMonitor" /etc/asterisk/extensions-vicidial.conf | head -5

You should see lines like:

MixMonitor(/var/spool/asterisk/monitor/...

If missing, update your dialplan or ensure ViciDial's call center is configured to record calls (Web admin > Agents > Settings).

Frequently asked questions

What NISQA score should I use as a threshold for re-recording or escalation?

Use 2.5 as a warning threshold (review the call) and 2.0 as a failure threshold (re-record for compliance or training). Calls below 2.0 are typically unintelligible to humans. Anything above 3.5 is acceptable for all uses. Thresholds depend on your SLA. QA operations often flag 2.5-3.0 for supervisor review and automatically reject 1.5-2.5.

Can I use NISQA without Silero VAD?

Yes. NISQA alone gives you overall speech quality. However, NISQA doesn't distinguish between silence, background noise, and actual speech quality. A call that is 90% silence will score higher than a loud, cl

Stuck on something specific?

Book a free 30-minute call. I run ViciDial centers across 3 countries and can usually unblock your setup in one session — or build it for you.

Book a Free Consultation