How it works¶
voicequal is built around a mixing-SNR estimate and a short ladder of thresholds on it. The intuition: a loud room only matters if your voice isn't dominant, so the job is to estimate how much louder the voice is than the noise, without a clean reference, for both speech and singing.
Two cheap estimators cover the two cases, and the pipeline takes the larger:
snr_estimate = max(HNR, energy SNR)
Each one under-reads when its assumption fails, so the max picks the estimator whose assumption held.
The harmonic-to-noise ratio¶
For each frame, voicequal removes the mean, applies a Hann window,
computes the autocorrelation via FFT, normalises it by lag zero, and
divides by the window's own autocorrelation (Boersma, 1993). The peak
r of that curve within the pitch-lag range (60–800 Hz) approximates the
fraction of energy that is periodic, and
HNR = 10 · log10( r / (1 − r) )
For a periodic signal plus additive noise this is, to a good approximation, the mixing SNR. On the benchmark's 120 mixed clips it tracks the true mixing SNR with a mean absolute error of 4.35 dB. The spectral peak-vs-floor SNR that v0.1.x used is off by 28 dB on the same clips, because a sung vowel buried in noise still has a dominant harmonic peak.
Per-file (and per rolling window in live mode), HNR is aggregated as the median over the louder half of frames, so breaths and gaps do not drag it down. It is the right tool for sustained singing. On read speech it under-reads by several dB, because consonants, breaths and pauses are unvoiced by nature.
The energy SNR¶
Speech has pauses. Cut the audio into 25 ms blocks, take the mean power of the loudest half of blocks as speech-plus-noise and the mean power of the quietest 30% as noise, and
energy SNR = 10 · log10( (P_loud − P_quiet) / P_quiet )
is the mixing SNR. On VoiceBank-DEMAND speech it tracks the true SNR with a rank correlation above 0.9. On sustained singing there are no pauses, so the "quiet" blocks still contain voice and the estimate collapses toward 0 dB. That is exactly when HNR takes over.
The decision¶
room < 60 dBA → excellent (quiet room, nothing to fix)
snr_estimate ≥ 18.5 dB → excellent (voice dominates noise)
13.5–18.5 dB → good
10–13.5 dB → fair
< 10 dB → poor (noise dominates)
The quiet-room gate comes first because a room below 60 dBA is fine no matter what the voice metrics say (and a noise-only clip has no voice to measure). The ladder was fitted jointly on a speech set and a singing set; see the benchmark page for both.
Older paths are kept for compatibility: assess_quality() with only
hnr uses the v0.2.0 HNR ladder (14.5 / 11 / 7 dB), and with neither
hnr nor snr_estimate it uses the v0.1.x spectral-SNR-gated logic.
The metrics¶
| Metric | What it captures | Role |
|---|---|---|
| SNR estimate | max(HNR, energy SNR): the mixing-SNR estimate, in dB | Drives tier |
| HNR | Periodic (voice) energy vs aperiodic (noise) energy, in dB | Feeds estimate |
| Energy SNR | Loud 25 ms blocks vs quiet blocks, in dB | Feeds estimate |
| Background dBA | Room loudness, median of recent RMS | Quiet-room gate |
| SNR (spectral) | How much louder the peak bin is than the noise floor | Informational |
| Spectral flatness | How "noise-like" (chaotic) vs "tonal" (structured) it is | Informational |
| Spectral concentration | Share of energy in the top-3 FFT bins | Informational |
| Temporal variance | Is noise sustained (fan) or transient (a passing car) | Informational |
| Clipping ratio | Fraction of samples at or above full scale | Informational |
Frame processing¶
Audio is resampled to 16 kHz and cut into 2048-sample frames with a 1600-sample hop (about 10 frames per second). Each frame produces the per-frame metrics above. A rolling state object keeps short histories:
- Room loudness is the median of the last ~3 s of RMS. It tracks sustained noise and ignores single-frame silences.
- Temporal variance is the standard deviation of the last ~2 s of noise-floor readings.
- HNR and the energy SNR are aggregated over the same ~3 s window in live mode, and over the whole file in file mode.
Streaming stability¶
Streaming is stabilized with a hysteresis buffer. A new tier has to
persist for 3 frames (configurable via stability_frames) before it is
announced, so single-frame blips do not cause flicker.
Calibration¶
Background dBA is 20·log10(rms) + db_offset. The offset is chosen so a
quiet room reads about 40 dBA. voicequal listen --calibrate measures
your mic's quiet and loud RMS and solves for the offset, then saves it
to ~/.voicequal/calibration.json.
Why not a voice activity detector?
The first v0.2.0 attempt built an energy-domain SNR on top of a concentration-based voice activity detector. It failed: sustained sung vowels stay tonal under heavy noise, so the detector rated noisy mixes as more voice-like than clean vocals. HNR sidesteps the problem by measuring periodicity directly, so no separate VAD is needed for this fix.