Inspection¶
kudio.core.report
¶
What can be said about a recording with no clean reference to compare it to.
Every quality metric in kudio.core.evaluator -- SNR, SI-SDR, PESQ,
STOI -- needs the clean signal alongside the degraded one. Synthetic datasets
have that. Real recordings never do, and those are the ones you actually
need to judge: score a denoised field recording against the noisy original and
you measure how much you changed it, not whether it got better.
This module is the half of that gap which costs nothing: level, clipping, DC offset, how much of the file is silence, where the noise floor sits, a crude SNR estimate, and how much bandwidth the content really occupies. None of it predicts a listener's opinion -- that needs a trained model such as DNSMOS or NISQA -- but all of it catches the mistakes that make a dataset quietly useless before anyone gets as far as an opinion.
Classes:
| Name | Description |
|---|---|
AudioReport |
Reference-free measurements of one clip. See |
Functions:
| Name | Description |
|---|---|
audio_report |
Inspect y without needing a clean reference to compare it against. |
AudioReport
dataclass
¶
AudioReport(
sr: int,
duration: float,
peak_dbfs: float,
rms_dbfs: float,
lufs: float,
clipped_samples: int,
clipped_ratio: float,
dc_offset: float,
silence_ratio: float,
noise_floor_dbfs: float,
estimated_snr_db: float,
bandwidth_hz: float,
band_limited: bool,
non_finite_samples: int = 0,
)
Reference-free measurements of one clip. See audio_report.
Methods:
| Name | Description |
|---|---|
problems |
The findings worth acting on, in plain words. Empty is good news. |
Attributes:
| Name | Type | Description |
|---|---|---|
peak_dbfs |
float
|
dBFS of the loudest sample; |
lufs |
float
|
BS.1770 integrated loudness, or |
clipped_samples |
int
|
samples inside a run of at least three pinned to full scale |
dc_offset |
float
|
mean sample value; a healthy recording sits at zero |
estimated_snr_db |
float
|
loud frames against quiet frames -- an estimate, not a measurement |
bandwidth_hz |
float
|
highest frequency still carrying content |
band_limited |
bool
|
content stops well below Nyquist: upsampled, or through a narrow codec |
non_finite_samples |
int
|
samples that are NaN or inf; with any of these the rest is meaningless |
peak_dbfs
instance-attribute
¶
dBFS of the loudest sample; -inf for digital silence
lufs
instance-attribute
¶
BS.1770 integrated loudness, or nan when the clip is too short
clipped_samples
instance-attribute
¶
samples inside a run of at least three pinned to full scale
estimated_snr_db
instance-attribute
¶
loud frames against quiet frames -- an estimate, not a measurement
band_limited
instance-attribute
¶
content stops well below Nyquist: upsampled, or through a narrow codec
non_finite_samples
class-attribute
instance-attribute
¶
samples that are NaN or inf; with any of these the rest is meaningless
problems
¶
The findings worth acting on, in plain words. Empty is good news.
Source code in kudio/core/report.py
audio_report
¶
audio_report(y: ndarray, sr: int) -> AudioReport
Inspect y without needing a clean reference to compare it against.
Mono only -- mix down first if you have channels; the questions this answers are about the recording, not the mix.
>>> report = kudio.audio_report(y, sr)
>>> for problem in report.problems():
... print(problem)
content stops at 3844 Hz although the file claims 16000 Hz — ...
A clip carrying NaN or inf is measured and counted rather than refused:
the count is the finding, reported first by AudioReport.problems.
The peak, RMS and loudness come out nan; the noise floor, the SNR estimate
and the silence share are taken over the frames that are finite. Inspecting
a corpus is exactly when you want to be told which files are broken, not
stopped at the first one.
The noise floor is read from frames with the DC offset taken out, so an
offset is reported as an offset rather than as noise. When at least a
tenth of the frames are digital silence, the floor is digital silence:
-inf, with an SNR estimate of inf.
Clipping is counted in runs of three or more pinned samples; a run of one or two is indistinguishable from a peak that touched full scale.
Raises:
| Type | Description |
|---|---|
FeatureError
|
on a non-mono array or a non-positive rate. |
Source code in kudio/core/report.py
225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 | |
kudio.core.loudness
¶
Perceptual loudness to ITU-R BS.1770-4, and normalisation based on it.
normalize and normalize_db scale by the peak sample, which says
nothing about how loud something sounds: a clip with one stray transient
normalises to a whisper. BS.1770 answers the question peak normalisation
cannot -- how loud is this to a listener -- by K-weighting the signal,
measuring mean square over 400 ms blocks, and gating away the silence between
words so a pause does not drag the number down.
Why this matters beyond metering: an A/B comparison between two versions of
a clip is not valid unless they are loudness matched. The louder one wins,
whatever its quality. match_loudness is the primitive for that.
The standard publishes its K-weighting coefficients at 48 kHz only. Speech work mostly happens at 8 or 16 kHz, so the filters here are re-discretised to the requested rate through a prewarped bilinear transform: exact at 48 kHz by construction, and within 0.07 dB of it at 16 kHz and 0.29 dB at 8 kHz, from 20 Hz to Nyquist.
Classes:
| Name | Description |
|---|---|
LoudnessCurve |
Loudness measured over a sliding window, in LUFS. |
Functions:
| Name | Description |
|---|---|
loudness |
Gated integrated loudness of y, in LUFS. |
loudness_over_time |
Loudness measured over a sliding window -- where a take drifted. |
loudness_range |
Loudness range in LU, to EBU Tech 3342. |
true_peak |
Peak of the reconstructed waveform, in dBTP (BS.1770-4 Annex 2). |
normalize_lufs |
Scale y to lufs integrated loudness. Silence is returned unchanged. |
match_loudness |
Scale y until it is as loud as reference. |
Attributes:
| Name | Type | Description |
|---|---|---|
MOMENTARY |
The two EBU Tech 3341 meter windows, in seconds. Momentary follows |
|
SHORT_TERM |
The short-term window, in seconds (EBU Tech 3341). |
BLOCK_SIZE
module-attribute
¶
Block length and hop of the gating measurement, in seconds (75% overlap).
MOMENTARY
module-attribute
¶
The two EBU Tech 3341 meter windows, in seconds. Momentary follows syllables; short-term follows how loud a passage is.
CURVE_HOP
module-attribute
¶
Seconds between measurements on a curve -- what both meters step by.
LoudnessCurve
dataclass
¶
Loudness measured over a sliding window, in LUFS.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
lufs
|
ndarray
|
one value per hop; |
required |
times
|
ndarray
|
centre time of each window, in seconds. A meter reports at the end of its window; a picture drawn beside a waveform has to line up with the audio that produced it, and the centre is where that is. |
required |
Methods:
| Name | Description |
|---|---|
loudest |
|
quietest |
|
spans_below |
Where the curve sits below lufs, as |
spans_above |
Where the curve sits above lufs, as |
summary |
Everything above as plain numbers, for a report or a CSV row. |
Attributes:
| Name | Type | Description |
|---|---|---|
finite |
ndarray
|
The measurements that are not digital silence. |
range_lu |
float
|
The EBU Tech 3342 statistic over this curve, in LU. |
range_lu
property
¶
The EBU Tech 3342 statistic over this curve, in LU.
It is the loudness range when the curve is short-term, which is
what loudness_range builds. On a momentary curve the same
arithmetic still runs and means something narrower -- how much the
syllables vary -- so it is not called LRA here.
Two gates: an absolute one at -70 LUFS, and a relative one 20 LU below the power-mean of what survived it (not the integrated measurement's 10 LU). What remains is measured from its 10th to its 95th percentile, which is what stops a single door slam from setting the answer.
0.0 when nothing survives the gates: a silent clip has no range
rather than an undefined one.
loudest
¶
quietest
¶
(seconds, lufs) of the quietest window that is not silence.
Silence is excluded deliberately: the quietest moment of a take is almost always a pause, and "your quietest moment is the gap between two words" is not a finding.
Source code in kudio/core/loudness.py
spans_below
¶
Where the curve sits below lufs, as (start, end) seconds.
spans_above
¶
Where the curve sits above lufs, as (start, end) seconds.
summary
¶
Everything above as plain numbers, for a report or a CSV row.
Source code in kudio/core/loudness.py
loudness
¶
loudness(
y: ndarray, sr: int, block_size: float = BLOCK_SIZE
) -> float
Gated integrated loudness of y, in LUFS.
Mono, or (samples, channels) -- channels last, the layout
soundfile returns. Digital silence measures -inf; a clip with NaN
or inf in it measures nan, with a warning, rather than reading as
silence.
block_size is the gating block in seconds. The standard says 400 ms and that is what you want; lowering it is the only way to measure something shorter than a block, and it is then no longer a BS.1770 measurement.
Source code in kudio/core/loudness.py
loudness_over_time
¶
loudness_over_time(
y: ndarray,
sr: int,
*,
window: float = SHORT_TERM,
hop: float = CURVE_HOP,
) -> LoudnessCurve
Loudness measured over a sliding window -- where a take drifted.
loudness returns one number for a whole recording, which is what
you normalise against and useless for finding the moment somebody turned
away from the microphone.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
window
|
float
|
|
SHORT_TERM
|
hop
|
float
|
seconds between measurements; 100 ms is what the meters use. These are ungated. Gating belongs to the integrated figure, where its job is to stop pauses dragging the average down. A curve is a continuous quantity, and one with holes punched in it where the gate fired would describe a recording that stops existing between words. |
CURVE_HOP
|
Source code in kudio/core/loudness.py
loudness_range
¶
Loudness range in LU, to EBU Tech 3342.
How far the loud parts sit above the quiet parts once the silence and the outliers are out of the way. A voice recording with a consistent delivery lands near 3-6 LU; one where somebody walked away from the microphone does not, and the integrated figure cannot tell you which you have.
It is the 10th-to-95th percentile spread of the short-term loudness, after two gates: an absolute one at -70 LUFS, and a relative one 20 LU below the power-mean of what survived it. The percentiles are what keep a single door slam from setting the answer, and the -20 LU gate -- not the integrated measurement's -10 -- is the one Tech 3342 specifies.
Returns 0.0 when nothing survives the gates; a silent or very short
clip has no range rather than an undefined one. A clip with NaN or inf in
it returns nan, with a warning.
If you already have the short-term curve, read
LoudnessCurve.range_lu instead -- this builds one and asks it, and
measuring the curve twice is the expensive half of both.
Source code in kudio/core/loudness.py
true_peak
¶
Peak of the reconstructed waveform, in dBTP (BS.1770-4 Annex 2).
The largest sample is not the largest value the signal reaches. Samples are points on a curve that passes between them, and the curve overshoots: a full-scale sine at a quarter of the sample rate, sampled at the zero crossings and the points between, reads far below the peak it actually has. Anything that reconstructs the waveform -- a converter, a lossy encoder, a resampler -- meets the real value and clips on it, which is why broadcast specifications are written in dBTP and not dBFS.
Measured by oversampling and taking the maximum. oversample defaults to whatever brings the rate to at least 192 kHz, which is what the standard asks for; at 48 kHz that is the familiar 4x.
Source code in kudio/core/loudness.py
normalize_lufs
¶
normalize_lufs(
y: ndarray,
sr: int,
lufs: float = -23.0,
block_size: float = BLOCK_SIZE,
) -> ndarray
Scale y to lufs integrated loudness. Silence is returned unchanged.
-23 LUFS is the EBU R 128 broadcast target. This applies a gain and nothing else, so the result can exceed full scale; that is warned about rather than silently limited, because clamping here would change the loudness you just asked for. Integer audio comes back in its own type, rounded, and there the clipping has already happened.
Raises:
| Type | Description |
|---|---|
FeatureError
|
Source code in kudio/core/loudness.py
match_loudness
¶
match_loudness(
y: ndarray,
reference: ndarray,
sr: int,
reference_sr: Optional[int] = None,
block_size: float = BLOCK_SIZE,
) -> ndarray
Scale y until it is as loud as reference.
The primitive behind a fair A/B: comparing a processed clip against its source tells you about the processing only once the level difference is out of the way, since the louder of two clips is reliably preferred regardless of quality. Returns y unchanged when either side is silent, and integer audio in its own type, rounded and clipped.
Raises:
| Type | Description |
|---|---|
FeatureError
|
when either clip has NaN or inf in it, and so no loudness to match. reference_sr defaults to sr. Pass it when the two were captured at different rates -- loudness is a property of the sound, not of the sampling, so the two are still comparable. |
Source code in kudio/core/loudness.py
kudio.core.dnsmos
¶
DNSMOS: a predicted listener opinion, with no clean reference.
kudio.audio_report answers "is this recording technically sound".
It cannot answer "does it sound good" — that is a judgement, and the only
reference-free way to get one without a listening panel is a model trained on
one. DNSMOS (ITU-T P.835) is that model: it returns three scores on the usual
1–5 MOS scale.
- SIG — the speech itself, ignoring the background.
- BAK — how unobtrusive the background is.
- OVRL — overall quality.
The interesting part for enhancement work is that SIG and BAK move in opposite directions: aggressive denoising raises BAK and lowers SIG, because it eats the speech along with the noise. A single number hides that trade; three do not.
kudio does not ship the model. The weights belong to Microsoft's DNS-Challenge and are distributed there; bundling ~2 MB of someone else's binary in a library that is otherwise pure source is not a thing to do quietly. Point model_dir at a checkout:
>>> kudio.dnsmos(y, sr, model_dir="DNS-Challenge/DNSMOS")
DnsmosScore(sig=3.42, bak=3.91, ovrl=3.15, segments=4)
Needs pip install kudio[dnsmos] for onnxruntime.
Classes:
| Name | Description |
|---|---|
DnsmosScore |
Three P.835 opinion scores, each on the 1–5 MOS scale. |
Functions:
| Name | Description |
|---|---|
find_dnsmos_model |
Locate |
dnsmos |
Predict P.835 opinion scores for y. |
MODEL_SR
module-attribute
¶
The model was trained at 16 kHz and on this segment length, both of which are part of the model rather than choices we are free to make.
MODEL_NAMES
module-attribute
¶
The P.835 model's file name in the DNS-Challenge distribution. The
model_v8.onnx beside it is the P.808 model, which gives one score, not
three, and is not looked for.
ENV_VAR
module-attribute
¶
The environment variable find_dnsmos_model reads when no
model_dir is given: the folder holding the model, or the model itself.
POLYFIT
module-attribute
¶
POLYFIT = {
"sig": (-0.08397278, 1.22083953, 0.0052439),
"bak": (-0.13166888, 1.60915514, -0.39604546),
"ovrl": (-0.06766283, 1.11546468, 0.04602535),
}
Published P.835 mapping from the network's raw outputs onto the MOS scale
(DNSMOS P.835, non-personalised). They are part of the released model, not a
tuning of ours -- which also means kudio cannot verify them without the
weights. polyfit=False returns the raw outputs if you would rather not
rely on them, and a polyfit.json in model_dir overrides them.
DnsmosScore
dataclass
¶
Three P.835 opinion scores, each on the 1–5 MOS scale.
Methods:
| Name | Description |
|---|---|
summary |
One line of plain words about what the numbers mean. |
Attributes:
| Name | Type | Description |
|---|---|---|
sig |
float
|
quality of the speech signal alone |
bak |
float
|
how unobtrusive the background is |
ovrl |
float
|
overall quality |
segments |
int
|
how many 9-second windows were averaged |
summary
¶
One line of plain words about what the numbers mean.
Source code in kudio/core/dnsmos.py
find_dnsmos_model
¶
Locate sig_bak_ovr.onnx.
Looks at model_dir, then at $KUDIO_DNSMOS_DIR. Either may be the
directory holding the file or the file itself.
Raises:
| Type | Description |
|---|---|
FeatureError
|
with instructions, when nothing is found. Being told where to get the model beats a FileNotFoundError on a path you did not choose. |
Source code in kudio/core/dnsmos.py
dnsmos
¶
dnsmos(
y: ndarray,
sr: int,
model_dir: Optional[PathLike] = None,
*,
polyfit: bool = True,
session: Optional[Any] = None,
) -> DnsmosScore
Predict P.835 opinion scores for y.
Mono only. Audio at any rate is resampled to the 16 kHz the model was trained at — the rate is a property of the model, not a preference.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
model_dir
|
Optional[PathLike]
|
directory (or file) holding |
None
|
polyfit
|
bool
|
map the network's raw outputs onto the MOS scale using the
published coefficients. |
True
|
session
|
Optional[Any]
|
a pre-built inference session, so a batch job opens the model once instead of per file. |
None
|
Raises:
| Type | Description |
|---|---|
DependencyError
|
when onnxruntime is not installed. |
FeatureError
|
on non-mono audio, an empty clip, or a missing model. |