Skip to content

fix(dsp): BS.1770 integrated loudness misses the 997 Hz calibration anchor - #860

Merged
lucasnewman merged 2 commits into
Blaizzy:mainfrom
gaoflow:fix-bs1770-integrated-loudness
Jul 31, 2026
Merged

fix(dsp): BS.1770 integrated loudness misses the 997 Hz calibration anchor#860
lucasnewman merged 2 commits into
Blaizzy:mainfrom
gaoflow:fix-bs1770-integrated-loudness

Conversation

@gaoflow

@gaoflow gaoflow commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Context

integrated_loudness() misses BS.1770's own calibration point. The Recommendation states that a 0 dB FS 997 Hz sine on one channel "will equal -3.01 LKFS"; on main it reads -3.0517. Cause: the RLB high-pass numerator is normalised by its denominator, where Table 2 specifies it as exactly [1, -2, 1]. That loses 0.043 dB across the whole band above ~500 Hz, so the -0.691 constant no longer cancels the K-weighting gain at 997 Hz (0.6496 dB measured, 0.691 dB required by Note 1).

Two further defects in the same path: the gating loop rounds the block count instead of flooring it, so a partial final block is measured but divided by a full block length — BS.1770 says incomplete gating blocks at the end are not used — which makes a steady tone's reading depend on clip length (0.19 LU between a 0.50 s and a 0.55 s tone at 24 kHz). And the first-pass absolute gate uses >= where equations (6)/(7) use >.

Description

Both K-weighting stages are now built from the analogue prototypes behind Tables 1 and 2, so they reproduce the tabulated 48 kHz coefficients to 1e-15 and rescale correctly to other rates; block indices are integer sample offsets and only complete blocks are measured. The 997 Hz anchor then reads -3.0103 LKFS and the reading no longer moves with clip length.

The existing tests pinned the old output to abs=1e-12, so they locked the error in rather than catching it. They are replaced by assertions against the Table 1/2 coefficients, the 997 Hz anchor, incomplete-block exclusion, and the two gating thresholds.

Verified out of tree against ffmpeg -filter:a ebur128 and pyloudnorm (which reaches the same coefficients under filter_class="DeMan"): EBU Tech 3341 cases 1-5 all land within 0.022 LU, and case 4 equals case 3 exactly, so the -70 LKFS gate is doing its job. Channel weights, gate thresholds, the 400 ms / 75% block geometry and the lfilter recurrence were audited against the Recommendation and are unchanged. pytest mlx_audio/tests/ is green.

integrated_loudness() missed the Recommendation's own calibration point: a
0 dB FS 997 Hz sine on one channel read -3.0517 LKFS instead of the -3.01
LKFS BS.1770 states, because the RLB high-pass numerator was normalised by
its denominator where Table 2 specifies exactly [1, -2, 1]. That cost
0.043 dB across the band above ~500 Hz, so the -0.691 constant no longer
cancelled the K-weighting gain at 997 Hz. Both stages are now built from
the analogue prototypes behind Tables 1 and 2 and reproduce the tabulated
48 kHz coefficients to 1e-15.

The gating loop also rounded the block count instead of taking the floor,
so a partial final block was measured but divided by a full block length.
BS.1770 says incomplete gating blocks at the end are not used. Block
indices are now integer sample offsets, which also made the reading of a
steady tone independent of clip length (it moved up to 0.19 LU before).
The first-pass absolute gate used >= where the Recommendation uses >.

The previous tests pinned the old output to abs=1e-12, so they locked the
error in rather than catching it; they are replaced by assertions against
the Table 1/2 coefficients, the 997 Hz anchor, incomplete-block exclusion,
and the two gating thresholds.
Comment thread mlx_audio/dsp.py
upper = int(block_size * (block_index * step + 1) * rate)
mean_square[channel, block_index] = (1.0 / (block_size * rate)) * np.sum(
np.square(input_data[lower:upper, channel])
lower = block_index * hop_samples

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this is quite correct? hop_samples is getting rounded up front now and will accumulate error at 11.025kHz (aka 44.1kHz / 4) since the hop sizes won't be exactly uniform, right? Can you add a test for 11.025kHz to make sure the hop sizes are correct?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The hop is now a constant integer, so spacing is exactly uniform — that's the fix, not the problem: the old code derived each boundary from block_size * block_index * step * rate in floats, which is what could drift. At 11.025 kHz the hop is round(1102.5) = 1102 samples (0.1 s to the nearest sample, per BS.1770), and every block starts at an exact integer multiple of it. Added test_integrated_loudness_block_hop_11025hz (7c44e39) asserting the block/hop values, uniform spacing, and the 997 Hz anchor (−26.010 at 48 kHz vs −25.969 at 11.025 kHz, within the per-rate filter redesign tolerance).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, fair enough, we're splitting hairs a bit and I agree the uniform step is likely ideal so I'm good with this. Thanks!

@lucasnewman lucasnewman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall this looks reasonable but please take a look at my one comment.

@lucasnewman
lucasnewman merged commit 223afd6 into Blaizzy:main Jul 31, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants