Auto QA Call Recording Quality with NISQA & Silero VAD
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:
- Asterisk 16 or later (18 preferred for codec stability)
- MariaDB 10.5+
- Call recordings in .wav or .mp3 format stored in /var/spool/asterisk/monitor/ or equivalent
- Python 3.8+
- Root or sudo access
- Disk space: 500MB minimum for NISQA/Silero models
Optional but recommended:
- Supervisor or QA user account in ViciDial
- Grafana or web dashboard for QA metrics
- Cron or systemd timer for automated scoring
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:
- NISQA scores overall intelligibility (1=bad, 5=excellent)
- Silero VAD measures percentage of active speech, identifies gaps, detects clipping or over-compression
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:
- Call ends, asterisk writes .wav or .mp3 to /var/spool/asterisk/monitor/
- ViciDial cron job moves or copies file to archive
- Your QA service picks up the file, decodes, runs NISQA + Silero VAD
- Scores are inserted into a ViciDial database table
- 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[Source:https://dev.to/moisi_trungu_31647b7ac300/auto-qa-call-recording-quality-with-nisqa-silero-vad-1c3b