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['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:
- 1.0-1.5: Nearly unintelligible. Artifacts, heavy compression, or noise dominate.
- 1.5-2.5: Poor. Speech recognizable but with effort. Typical of congested networks or very compressed codecs.
- 2.5-3.5: Fair. Acceptable for most applications but QA should review. Moderate artifacts.
- 3.5-4.5: Good. Commercial-grade audio. Suitable for compliance and training.
- 4.5-5.0: Excellent. Minimal artifacts, clear speech, no background noise.
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:
- ULAW (default): Wide compression, acceptable loss. NISQA 3.2-3.8 on clean networks.
- G.729: Narrow bandwidth, 8kHz, licensed codec. NISQA 2.5-3.2 due to aggressive compression.
- GSM: Mobile codec, poor fidelity. NISQA 2.0-2.8.
- OPUS or Speex: Modern codecs, better fidelity. NISQA 3.8-4.5.
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:
- Database credentials wrong in service file
- Recording directory path not found or not readable
- Permissions: script runs as root, but /var/spool/asterisk/monitor/ may be owned by asterisk user
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