Skip to content

Programme loudness and true peak (BS.1770 / EBU R 128)

Standards: ITU-R BS.1770EBU R 128EBU Tech 3341EBU Tech 3342EBU Tech 3343Key references: Steinmetz & Reiss 2021

ITU-R BS.1770-5 defines how broadcast and streaming measure the loudness of a programme: K-weighting, mean-square power in gated 400 ms blocks and a channel-weighted sum, reported in LKFS/LUFS. EBU R 128 builds the normalisation practice on top of it (every programme is levelled to −23.0 LUFS with a true-peak ceiling of −1 dBTP), and its companions EBU Tech 3341 and Tech 3342 add the EBU Mode meter (momentary, short-term and integrated loudness) and the loudness range (LRA). phonometry implements the full chain in the broadcast namespace and validates every synthesizable EBU test signal against its official tolerance.

The whole Recommendation is one metering chain, and each section below details one of its blocks. The diagram lays it out first, with the numbers of the examples on this page.

Block diagram of the BS.1770 and EBU R 128 programme-loudness chain: the channel-weighted programme, anchored so a full-scale 997 hertz sine on one front channel reads minus 3.01 LKFS, passes the K-weighting of a plus 4 decibel spherical-head shelf and the RLB high-pass, is measured in 400 millisecond mean-square blocks at 75 percent overlap, gated absolutely at minus 70 LUFS and relatively 10 loudness units below the survivors, minus 39.0 LUFS in the example of 10 seconds of programme plus 30 seconds of quiet, and yields an integrated loudness of minus 23.1 LUFS against the minus 23.0 target; side branches show the loudness range of 10.0 loudness units on the Tech 3342 two-step case and the true peak of plus 0.12 dBTP where the sample peak reads minus 3.01 decibels, under the minus 1 dBTP production ceilingBlock diagram of the BS.1770 and EBU R 128 programme-loudness chain: the channel-weighted programme, anchored so a full-scale 997 hertz sine on one front channel reads minus 3.01 LKFS, passes the K-weighting of a plus 4 decibel spherical-head shelf and the RLB high-pass, is measured in 400 millisecond mean-square blocks at 75 percent overlap, gated absolutely at minus 70 LUFS and relatively 10 loudness units below the survivors, minus 39.0 LUFS in the example of 10 seconds of programme plus 30 seconds of quiet, and yields an integrated loudness of minus 23.1 LUFS against the minus 23.0 target; side branches show the loudness range of 10.0 loudness units on the Tech 3342 two-step case and the true peak of plus 0.12 dBTP where the sample peak reads minus 3.01 decibels, under the minus 1 dBTP production ceiling

1. K-weighting and the loudness measure (Annex 1)

Section titled “1. K-weighting and the loudness measure (Annex 1)”

The signal first passes a two-stage pre-filter: a ~+4 dB high-frequency shelf modelling the head as a rigid sphere, then the RLB high-pass. The concatenation is the K-weighting. The loudness over an interval is the channel-weighted sum of the mean-square powers (Formula 2):

where the constant cancels the K-weighting gain at 997 Hz and weighs each channel (1.0 for the front channels, 1.41 for the surrounds, LFE excluded, Table 3). The Recommendation anchors the scale: a 0 dB FS 997 Hz sine on one front channel reads −3.01 LKFS. The unit is written LKFS by the ITU and LUFS by the EBU; they are identical, and 1 LU is 1 dB.

import numpy as np
from phonometry import broadcast
fs = 48000
t = np.arange(20 * fs) / fs
x = np.zeros((5, t.size)) # L, R, C, Ls, Rs
x[0] = np.sin(2 * np.pi * 997.0 * t) # 0 dB FS on the left channel
print(round(broadcast.integrated_loudness(x, fs), 2)) # -3.01 LKFS
# The same waveform on two channels instead of one (see below):
mono = np.sin(2 * np.pi * 997.0 * t)
print(round(broadcast.integrated_loudness(mono, fs), 2)) # -3.01
print(round(broadcast.integrated_loudness(np.vstack([mono, mono]), fs), 2)) # 0.00

Formula 2 sums over the channels, so the channel count is part of the measurement: the same waveform carried on two channels is exactly LU louder than on one. The anchor above shows it — that full-scale 997 Hz sine reads −3.01 LKFS on a single channel and 0.00 LKFS as dual mono, which is what the last two lines of the snippet above print.

Meter the delivery format, not a stem. A mono podcast delivered as one channel is compliant at −23 LUFS as one channel; duplicating it to stereo before metering makes it read −20 LUFS and invites a 3 dB correction in the wrong direction. The same arithmetic is why the LFE is excluded (Table 3, ) and why the surround weights are 1.41: the sum is a loudness sum over the reproduction layout, so changing the layout changes the number legitimately. The np.vstack in the snippets below is exactly this — dual mono, the stereo delivery of a mono source — and it is why they read 3.01 LU above the single-channel value of the same waveform.

The biquad coefficients are tabulated at 48 kHz (Tables 1-2) and returned verbatim at that rate; any other rate re-derives them through the analog prototype so the response matches the specification (within 0.02 dB at 32 kHz and above; rates below 16 kHz are rejected):

import numpy as np
from phonometry import broadcast
(b1, a1), (b2, a2) = broadcast.k_weighting_coefficients(48000)
print(b1) # [ 1.53512486 -2.69169619 1.19839281] (Table 1, verbatim)
y = broadcast.k_weighting(np.random.default_rng(0).standard_normal(48000),
48000) # the filtered signal itself

k_weighting_response evaluates those same biquads as a transfer function and returns a frozen KWeightingResponse carrying the combined magnitude (magnitude_db) and the two stages (shelf_db, highpass_db) over a logarithmic frequency grid; its .plot() draws the response, with the +4 dB spherical-head shelf and the RLB high-pass roll-off:

K-weighting magnitude frequency response on a logarithmic frequency axis: the combined blue curve rolls off below a few hundred hertz through the RLB high-pass and rises to a +4 dB plateau above 2 kHz set by the spherical-head shelf, with the two stages drawn as light companion curvesK-weighting magnitude frequency response on a logarithmic frequency axis: the combined blue curve rolls off below a few hundred hertz through the RLB high-pass and rises to a +4 dB plateau above 2 kHz set by the spherical-head shelf, with the two stages drawn as light companion curves

The two stages, and the whole of the frequency dependence in BS.1770. The RLB high-pass removes the sub-100 Hz energy the ear does not integrate into loudness, and the +4 dB spherical-head shelf above about 2 kHz stands in for the diffraction gain of a head in a sound field. The −0.691 constant in is what makes the two of them together read exactly −3.01 LKFS on a full-scale 997 Hz sine.

Show the code for this figure
import matplotlib.pyplot as plt
from phonometry import broadcast
broadcast.k_weighting_response(48000).plot()
plt.show()

That is the whole model: two biquads and a mean square. It is worth being explicit about what such a measure cannot do, because the question a reader arrives with is usually “is this the loudness?”. The chain is linear and level-independent by construction — doubling a programme’s gain adds exactly 6 dB to , which is precisely what makes normalisation a single multiplication, and precisely what a real ear does not do. There is no masking, no critical-band summation, no equal-loudness contour and no binaural summation anywhere in it. So BS.1770 ranks similar material reliably, which is what broadcast delivery needs, and is known to be less reliable across very different spectra — heavily low-frequency music against dialogue being the standard example, and the reason the RLB high-pass exists at all. When the question really is perceived magnitude rather than delivery level, the psychoacoustic models are in Loudness (ISO 532), which reports sones and a specific-loudness pattern instead of a single gated number.

The integrated (programme) loudness divides the measurement into gating blocks of 400 ms overlapping 75 % and gates them twice (Formulae 3-7): blocks below the absolute threshold −70 LKFS are dropped; the loudness of the survivors minus 10 LU sets the relative threshold, and the blocks above both gates define the result. The gate keeps long quiet passages (atmosphere, pauses, applause tails) from dragging the level of the foreground down:

import numpy as np
from phonometry import broadcast
fs = 48000
def tone(level_dbfs, seconds):
t = np.arange(int(seconds * fs)) / fs
return 10 ** (level_dbfs / 20) * np.sin(2 * np.pi * 1000.0 * t)
# 10 s of programme at -23 dBFS followed by 30 s of quiet ambience.
x = np.concatenate([tone(-23.0, 10.0), tone(-50.0, 30.0)])
res = broadcast.program_loudness(np.vstack([x, x]), fs)
print(round(res.integrated, 1)) # -23.1 LUFS (the tail is gated)
print(round(res.relative_threshold, 1)) # -39.0 LUFS
res.plot() # the loudness trace: the integrated line ignores the tail (needs matplotlib)

An ungated mean over the same 40 s would sit near −29 LUFS: the gating is what makes wide-loudness-range programmes match on air. EBU R 128 normalises this integrated value to −23.0 LUFS; where the target is not practically achievable (live programmes, for example) a tolerance of ±1.0 LU is permitted, and quality-control workflows allow ±0.2 LU for measurement error.

The gate also sets a floor on what can be measured at all. The integrated loudness needs at least one 400 ms gating block above the −70 LUFS absolute gate; below that — an item shorter than a block, or digital silence throughout — integrated_loudness returns -inf, which is a no measurement answer and not a very quiet programme. Just above that floor the answer is weak rather than absent: an item of a few seconds leaves only a handful of surviving blocks, so the integrated value inherits their scatter, which is why short-form delivery specifications normally normalise on the momentary or short-term maximum instead of on I. (Non-finite samples are rejected outright rather than gated.)

The order of those two passes is what makes the gate hard to picture from a finished trace. The relative threshold is not a constant the meter knows in advance: it is computed from the blocks that survived the absolute gate, so it only exists once there is material to compute it from, and it keeps moving while the programme plays. A block that was counted early can therefore stop counting later, without anything about that block having changed. The clip runs the decision block by block on a louder, five-section programme, so the threshold can be watched sliding:

Sixty seconds of a five-section programme are metered into 597 gating blocks, each drawn as a square that is solid while it counts and hollow once it is gated out. The dashed relative threshold is recomputed from the survivors after every block: it starts low, so every block counts, and climbs as louder material arrives until it settles at -34.3 LUFS, at which point blocks that were counted earlier have gone hollow behind it. The histogram beside the trace counts the same blocks into loudness bins, its bars greying below the sliding threshold. Four readouts settle at an integrated loudness of -23.0 LUFS against an ungated energy mean of -24.3 LUFS, a difference of 1.28 LU, and 154 of the 597 blocks never counted. A closing act applies the deeper loudness-range gate at -44.1 LUFS and slides the 10th and 95th percentile edges onto -36.8 and -19.1 LUFS for a loudness range of 17.7 LU.

Download the animation (WebM)

The gate is a decision taken per block, not a filter over the signal. Because the relative threshold is derived from the survivors of the first pass, it is data-dependent and retroactive: the quiet opening and the fade-out end up excluded not because they fell below a fixed level, but because the rest of the programme turned out to be loud enough to raise the threshold above them.

Sixty seconds of a five-section programme are metered into 597 gating blocks, each drawn as a square that is solid while it counts and hollow once it is gated out. The dashed relative threshold is recomputed from the survivors after every block: it starts low, so every block counts, and climbs as louder material arrives until it settles at -34.3 LUFS, at which point blocks that were counted earlier have gone hollow behind it. The histogram beside the trace counts the same blocks into loudness bins, its bars greying below the sliding threshold. Four readouts settle at an integrated loudness of -23.0 LUFS against an ungated energy mean of -24.3 LUFS, a difference of 1.28 LU, and 154 of the 597 blocks never counted. A closing act applies the deeper loudness-range gate at -44.1 LUFS and slides the 10th and 95th percentile edges onto -36.8 and -19.1 LUFS for a loudness range of 17.7 LU.

Download the animation (WebM)

The gate is a decision taken per block, not a filter over the signal. Because the relative threshold is derived from the survivors of the first pass, it is data-dependent and retroactive: the quiet opening and the fade-out end up excluded not because they fell below a fixed level, but because the rest of the programme turned out to be loud enough to raise the threshold above them.

The figure makes the gate visible on a shaped-noise programme with a long quiet tail:

EBU R 128 metering of 20 seconds of programme on the -23 LUFS target followed by 40 seconds of quiet ambience about 29 LU lower: the momentary and short-term traces step down at 20 seconds, the dashed integrated line stays at -23.0 LUFS because the relative gate drops the tail, and a dash-dotted line marks the ungated mean sinking to -27.7 LUFSEBU R 128 metering of 20 seconds of programme on the -23 LUFS target followed by 40 seconds of quiet ambience about 29 LU lower: the momentary and short-term traces step down at 20 seconds, the dashed integrated line stays at -23.0 LUFS because the relative gate drops the tail, and a dash-dotted line marks the ungated mean sinking to -27.7 LUFS

The relative gate (10 LU below the survivors) drops every block of the tail, so the integrated loudness holds the foreground at −23.0 LUFS while the ungated energy mean sinks towards −27.7 LUFS — and would keep sinking with every extra minute of ambience. Without the gate, quiet passages would let the foreground of a film mix ride far above the target.

Show the code for this figure
import matplotlib.pyplot as plt
import numpy as np
from scipy import signal
from phonometry import broadcast
fs = 48000
rng = np.random.default_rng(3341)
sos = signal.butter(2, 2000.0, fs=fs, output="sos")
chunks = []
# 20 s of programme material, then 40 s of quiet room ambience ~29 LU lower.
for level, seconds in [(-23.0, 20.0), (-52.0, 40.0)]:
noise = signal.sosfilt(sos, rng.standard_normal(int(seconds * fs)))
noise /= np.sqrt(np.mean(noise ** 2))
chunks.append(10 ** (level / 20) * noise)
x = np.concatenate(chunks)
# Loudness-normalise the programme to the R 128 target, then meter it.
x *= 10 ** ((-23.0 - broadcast.integrated_loudness(np.vstack([x, x]), fs)) / 20)
res = broadcast.program_loudness(np.vstack([x, x]), fs)
ax = res.plot()
finite = res.momentary[np.isfinite(res.momentary)]
ungated = 10 * np.log10(np.mean(10 ** (finite / 10)))
ax.axhline(ungated, ls="-.", color="#2ca02c",
label=f"Ungated mean {ungated:.1f} LUFS")
ax.legend(loc="center right")
plt.show()

3. EBU Mode: momentary, short-term, integrated

Section titled “3. EBU Mode: momentary, short-term, integrated”

EBU Tech 3341 defines the three time scales of a compliant meter, and one call computes them all:

  • Momentary (M): sliding 400 ms window, no gating;
  • Short-term (S): sliding 3 s window, no gating;
  • Integrated (I): the gated programme loudness above,

plus Max M and Max S, the true peak and the LRA:

import numpy as np
from phonometry import broadcast
fs = 48000
def tone(level_dbfs, seconds):
t = np.arange(int(seconds * fs)) / fs
return 10 ** (level_dbfs / 20) * np.sin(2 * np.pi * 1000.0 * t)
# EBU Tech 3341 test case 3: -36 / -23 / -36 dBFS steps.
x = np.concatenate([tone(-36.0, 10.0), tone(-23.0, 60.0), tone(-36.0, 10.0)])
res = broadcast.program_loudness(np.vstack([x, x]), fs)
print(round(res.integrated, 1), round(res.max_momentary, 1),
round(res.max_short_term, 1)) # -23.0 -23.0 -23.0
res.plot() # M/S traces, integrated line and LRA band (needs matplotlib)

The frozen ProgramLoudnessResult carries the M and S series with their time axes, the maxima, the thresholds, the LRA with its percentile edges, the per-channel true peaks and the channel weights; its .plot() draws the loudness trace of the programme:

EBU R 128 metering of a one-minute synthetic programme with ambience, dialogue, music and fade-out sections: the grey momentary loudness breathes around the blue short-term trace, the red dashed integrated loudness sits exactly on the -23 LUFS target, and a shaded band marks the loudness range between its 10th and 95th percentile edgesEBU R 128 metering of a one-minute synthetic programme with ambience, dialogue, music and fade-out sections: the grey momentary loudness breathes around the blue short-term trace, the red dashed integrated loudness sits exactly on the -23 LUFS target, and a shaded band marks the loudness range between its 10th and 95th percentile edges

One synthetic programme through the three time scales: quiet ambience, then dialogue, then music, then a fade-out. The momentary trace (400 ms) breathes with every syllable; the short-term trace (3 s) is the one an operator rides, and it departs from M wherever the material is dense or transient rather than steady. The integrated line is flat by definition — it is one number for the whole programme — and the shaded band is the P10 to P95 spread that the loudness range reports.

Show the code for this figure
import matplotlib.pyplot as plt
import numpy as np
from scipy import signal
from phonometry import broadcast
fs = 48000
rng = np.random.default_rng(1770)
sos = signal.butter(2, 2000.0, fs=fs, output="sos")
chunks = []
for level, seconds in [(-38, 8), (-23, 16), (-17, 12), (-25, 16), (-45, 8)]:
noise = signal.sosfilt(sos, rng.standard_normal(int(seconds * fs)))
noise /= np.sqrt(np.mean(noise ** 2))
t = np.arange(noise.size) / fs
wobble = 1 + 0.22 * np.sin(2 * np.pi * 0.9 * t) \
+ 0.14 * np.sin(2 * np.pi * 2.83 * t + 1.0)
chunks.append(10 ** (level / 20) * noise * wobble)
x = np.concatenate(chunks)
# Normalise the programme to the R 128 target, then meter it.
gain = -23.0 - broadcast.integrated_loudness(np.vstack([x, x]), fs)
x *= 10 ** (gain / 20)
broadcast.program_loudness(np.vstack([x, x]), fs).plot()
plt.show()

The loudness range quantifies how much the loudness varies on a macroscopic time scale, in LU. It is the spread between the 10th and 95th percentiles of the short-term loudness distribution after a cascaded gate: an absolute threshold at −70 LUFS, then a relative threshold −20 LU below the level of what survived (deliberately deeper than the −10 LU of the integrated measure, so quiet-but-real foreground still counts). The percentiles keep a single gunshot or a fade-out from inflating the value:

import numpy as np
from phonometry import broadcast
fs = 48000
def tone(level_dbfs, seconds):
t = np.arange(int(seconds * fs)) / fs
return 10 ** (level_dbfs / 20) * np.sin(2 * np.pi * 1000.0 * t)
# EBU Tech 3342 test case 1: 20 s at -20 dBFS, then 20 s at -30 dBFS.
x = np.concatenate([tone(-20.0, 20.0), tone(-30.0, 20.0)])
res = broadcast.program_loudness(np.vstack([x, x]), fs)
print(round(res.loudness_range, 1)) # 10.0 LU
res.plot() # the shaded LRA band spans the P10-P95 spread (needs matplotlib)
EBU R 128 metering of the Tech 3342 reference case of 20 seconds at -20 dBFS followed by 20 seconds at -30 dBFS: the short-term trace steps between two plateaus 10 LU apart, the shaded loudness-range band spans exactly those plateaus for LRA equal to 10.0 LU, and the integrated line sits between themEBU R 128 metering of the Tech 3342 reference case of 20 seconds at -20 dBFS followed by 20 seconds at -30 dBFS: the short-term trace steps between two plateaus 10 LU apart, the shaded loudness-range band spans exactly those plateaus for LRA equal to 10.0 LU, and the integrated line sits between them

On the Tech 3342 reference case the short-term distribution has two plateaus 10 LU apart, and the shaded band between the 10th and 95th percentile edges reads exactly ; the integrated loudness settles between the plateaus. On real programmes the same band tells a dialogue-normalised drama (LRA around 10-20 LU) from a compressed commercial (a few LU) at a glance.

Show the code for this figure
import matplotlib.pyplot as plt
import numpy as np
from phonometry import broadcast
fs = 48000
def tone(level_dbfs, seconds):
t = np.arange(int(seconds * fs)) / fs
return 10 ** (level_dbfs / 20) * np.sin(2 * np.pi * 1000.0 * t)
# EBU Tech 3342 test case 1: 20 s at -20 dBFS, then 20 s at -30 dBFS.
x = np.concatenate([tone(-20.0, 20.0), tone(-30.0, 20.0)])
res = broadcast.program_loudness(np.vstack([x, x]), fs)
res.plot() # the LRA band spans exactly the 10 LU between the plateaus
plt.show()

loudness_range() is also available standalone on any short-term loudness vector, following the Tech 3342 reference implementation (including its nearest-rank percentile indexing). The EBU does not recommend LRA for programmes shorter than a minute: too few 3 s windows.

Digital sample peaks lie: the true maximum of the reconstructed waveform generally falls between samples, and a sample-peak meter under-reads a badly phased tone at by 3 dB. The worst case is bounded by , where is the oversampling ratio and is the tone frequency normalised to the sampling rate — which is exactly why is the worst case, since maximises the bound over the audio band. BS.1770-5 Annex 2 therefore meters the true peak on a signal oversampled to at least 192 kHz (4× at 48 kHz), in dBTP (dB relative to 100 % full scale):

import numpy as np
from phonometry import broadcast
fs = 48000
t = np.arange(fs) / fs
# A full-scale fs/4 tone whose peaks fall exactly between samples.
x = np.sin(2 * np.pi * (fs / 4) * t + np.pi / 4)
print(round(float(broadcast.true_peak_level(x, fs, oversample=1)), 2)) # -3.01
print(round(float(broadcast.true_peak_level(x, fs)), 2)) # 0.12

The interpolator recovers the inter-sample excursion the sample grid missed (the residual +0.12 dB is interpolation ripple from the abrupt tone edges, inside the +0.2/−0.4 dB tolerance that EBU Mode meters must meet).

Two panels. Left, twelve samples of the full-scale 12 kHz tone at a quarter-cycle phase offset: every sample dot sits at 0.707 of full scale on the dashed sample-peak line at minus 3.01 dBFS, while the band-limited reconstruction drawn through them touches plus and minus one, and the 4 times oversampled grid is marked along the bottom. Right, the worst-case under-read 20 log cos of pi f_norm over n against normalised frequency for oversampling ratios of 1, 2, 4 and 8, with the BS.1770 minimum of 4 marked at f_norm equal to one quarter where minus 0.17 decibel remainsTwo panels. Left, twelve samples of the full-scale 12 kHz tone at a quarter-cycle phase offset: every sample dot sits at 0.707 of full scale on the dashed sample-peak line at minus 3.01 dBFS, while the band-limited reconstruction drawn through them touches plus and minus one, and the 4 times oversampled grid is marked along the bottom. Right, the worst-case under-read 20 log cos of pi f_norm over n against normalised frequency for oversampling ratios of 1, 2, 4 and 8, with the BS.1770 minimum of 4 marked at f_norm equal to one quarter where minus 0.17 decibel remains

Left: the mechanism. Sampling a full-scale tone at the wrong phase puts every sample at , so a sample-peak meter reads −3.01 dBFS while the waveform that will actually leave a converter reaches full scale. Right: the bound, against the oversampling ratio. At the 4× BS.1770 asks for, 0.17 dB of the excursion is still missed at the worst frequency — which is part of why the ceiling is −1 dBTP and not 0.

Show the code for this figure
import matplotlib.pyplot as plt
# The page's own signal, metered whole and drawn twelve samples at a time.
sample_peak = float(broadcast.true_peak_level(x, fs, oversample=1))
true_peak = float(broadcast.true_peak_level(x, fs))
t_fine = np.linspace(0.0, 11 / fs, 2000)
fine = np.sin(2 * np.pi * (fs / 4) * t_fine + np.pi / 4)
fig, (axl, axr) = plt.subplots(1, 2, figsize=(12, 5))
axl.plot(t_fine * 1000.0, fine, label="band-limited reconstruction")
axl.plot(np.arange(12) / fs * 1000.0, x[:12], "o", label="samples at 48 kHz")
axl.axhline(np.abs(x[:12]).max(), linestyle="--") # sample peak, -3.01 dBFS
axl.axhline(1.0, linestyle="--") # the true excursion
axl.set(xlabel="Time [ms]", ylabel="Amplitude [FS]")
axl.legend()
f_norm = np.linspace(0.0, 0.5, 400)
for ratio in (1, 2, 4, 8):
axr.plot(f_norm, 20.0 * np.log10(np.cos(np.pi * f_norm / ratio)),
label=f"n = {ratio}")
axr.set(xlabel="Tone frequency / sampling rate", ylabel="Under-read [dB]",
ylim=(-7.0, 0.4))
axr.legend()
plt.show()

EBU R 128 caps production at −1 dBTP; distribution codecs often need more headroom. This is the same oversampled-peak machinery behind the C-weighted lc_peak of Integrated & Statistical Levels.

With 1, 2, 5 or 6 channels the Table 3 weights apply automatically (channel order L, R, C, Ls, Rs, or L, R, C, LFE, Ls, Rs with the LFE excluded). For any other loudspeaker layout (22.2, 4+7+0 and the rest of the BS.2051 advanced sound systems), Annex 3 derives the weight of each channel from its loudspeaker position: 1.41 (+1.5 dB) for mid-layer side loudspeakers (, ), 1.0 elsewhere:

from phonometry import broadcast
print(broadcast.channel_weight(110.0, 0.0)) # 1.41 (M+110, side)
print(broadcast.channel_weight(110.0, 35.0)) # 1.0 (U+110, upper layer)
weights = broadcast.channel_weight([0, 30, -30, 90, -90], [0, 0, 0, 0, 0])
# -> [1. 1. 1. 1.41 1.41]; pass as program_loudness(..., weights=weights)

Those are two inequalities describing a picture, and the picture answers the question a rigger actually asks — which loudspeakers of this layout fall inside the zone:

A map of the BS.1770 Annex 3 channel weight over azimuth from minus 180 to 180 degrees and elevation from minus 90 to 90 degrees. Two shaded rectangles between 60 and 120 degrees of azimuth on either side and within plus or minus 30 degrees of elevation carry the weight 1.41, plus 1.5 decibels; everything else is 1.0. The 5.1 loudspeakers L, R and C sit at weight 1.00, Ls and Rs at 110 degrees fall inside the shaded zones at 1.41, and the upper-layer U plus 110 at 45 degrees of elevation falls outside at 1.00A map of the BS.1770 Annex 3 channel weight over azimuth from minus 180 to 180 degrees and elevation from minus 90 to 90 degrees. Two shaded rectangles between 60 and 120 degrees of azimuth on either side and within plus or minus 30 degrees of elevation carry the weight 1.41, plus 1.5 decibels; everything else is 1.0. The 5.1 loudspeakers L, R and C sit at weight 1.00, Ls and Rs at 110 degrees fall inside the shaded zones at 1.41, and the upper-layer U plus 110 at 45 degrees of elevation falls outside at 1.00

The weight is a property of the loudspeaker’s position, not of the channel’s name. Ls and Rs of a 5.1 layout land inside the mid-layer side zone and take +1.5 dB; the same azimuth lifted to the upper layer does not. That is why Table 3 and Annex 3 agree on 5.1 and part company on 22.2.

Show the code for this figure
import matplotlib.pyplot as plt
# The map is the function itself, evaluated over the sphere.
az, el = np.meshgrid(np.linspace(-180.0, 180.0, 361),
np.linspace(-90.0, 90.0, 181))
weight = broadcast.channel_weight(az, el)
fig, ax = plt.subplots(figsize=(11, 5.6))
ax.contourf(az, el, weight, levels=[1.2, 2.0])
ax.contour(az, el, weight, levels=[1.2])
for name, a, e in (("L", -30.0, 0.0), ("R", 30.0, 0.0), ("C", 0.0, 0.0),
("Ls", -110.0, 0.0), ("Rs", 110.0, 0.0),
("U+110", 110.0, 45.0)):
ax.plot([a], [e], "o")
ax.annotate(f"{name} ({broadcast.channel_weight(a, e):.2f})", (a, e))
ax.set(xlabel="Azimuth [°]", ylabel="Elevation [°]")
plt.show()

Object-based audio (Annex 4) is measured by rendering to a loudspeaker configuration first and metering the render; the rendering itself is out of scope here.

Metering is half of R 128; the other half is the single gain that follows it, and the order of operations matters more than the arithmetic does.

First, what program_loudness expects: a [channels, samples] array in full-scale units, where 1.0 is 0 dBFS. A soundfile.read returns (samples, channels), so it has to be transposed, and integer PCM has to be scaled to ±1 first. Second, what is metered: the programme as delivered. Line-up tone, slate, countdown and black are part of the file and not part of the programme, so they are trimmed before metering — a 1 kHz line-up tone at −18 dBFS sails through the absolute gate and drags the integrated value with it. And the whole programme is measured, not an excerpt, because the relative gate is computed from the programme’s own survivors.

Then the gain. Normalisation is a single static gain of (target − I) dB applied to the whole programme:

from scipy import signal
# A finished stereo programme, standing in for the delivered master: three
# sections of shaped noise 13 LU apart. In practice this is
# data, fs = soundfile.read(path) # (samples, channels), -1.0 to 1.0
# programme = data.T # program_loudness wants [ch, samples]
rng = np.random.default_rng(128)
sos = signal.butter(2, 2000.0, fs=fs, output="sos")
sections = []
for level_dbfs, seconds in [(-31.0, 12.0), (-24.0, 12.0), (-37.0, 12.0)]:
noise = signal.sosfilt(sos, rng.standard_normal(int(seconds * fs)))
sections.append(10 ** (level_dbfs / 20) * noise / np.sqrt(np.mean(noise ** 2)))
programme = np.concatenate(sections)
res = broadcast.program_loudness(np.vstack([programme, programme]), fs)
gain_db = -23.0 - res.integrated
print(round(res.integrated, 2), round(gain_db, 2)) # -23.86 0.86
normalised = programme * 10 ** (gain_db / 20)
after = broadcast.program_loudness(np.vstack([normalised, normalised]), fs)
print(round(after.integrated, 2), round(after.loudness_range, 2)) # -23.0 13.02
print(round(after.true_peak, 2)) # -9.96, was -10.82
print(after.true_peak <= -1.0) # the R 128 ceiling: True

Because the gain is linear and applies everywhere, it shifts M, S and I by exactly that many decibels and leaves LRA unchanged — the loudness range is a difference of percentiles, and a constant offset cancels out of it. Which blocks survive the gate is unchanged too, for the same reason.

The true peak, however, moves with the gain, and that is the one conflict R 128 leaves you to resolve. A quiet, wide-range programme needing +8 LU whose maximum true peak already sits at −6 dBTP lands at +2 dBTP: normalised correctly and over the ceiling. The order is normalise, then check — never reduce the gain to fit the peak, because that breaks the delivery level everything else depends on. The two legitimate resolutions are a true-peak limiter applied to the loudest moments (which lowers LRA slightly, and should be declared) or a renegotiated target for that delivery.

The delivery record is three numbers, not one: the integrated loudness I, the loudness range LRA and the maximum true peak max TP. They are exactly the three the fiche below boxes.

ProgramLoudnessResult.report(path) renders a one-page PDF compliance fiche laid out like a broadcast loudness-delivery sheet: a standard-basis line, an optional metadata header block, a full-width compliance table (Metric | Measured | Target / Limit | Result) and, below it, the full-width loudness-vs-time plot (the result’s own .plot(), with the momentary and short-term traces, the integrated line and the LRA band). The verdict is driven only by the integrated loudness and the maximum true peak; the loudness range and the momentary/short-term maxima are shown as informational rows (an en dash in the Result column, never a pass/fail colour). A boxed I = X LUFS (LRA = Y LU, max TP = Z dBTP) single number, a combined PASS/FAIL verdict and a footer with the fixed disclaimer close the sheet.

The stacked layout (compliance table on top, plot below) differs from the narrow two-panel body of the other fiches because the compliance table needs four columns and the loudness-vs-time trace is landscape. It uses the same ReportMetadata container and rendering engine as the ISO 717 insulation fiche.

What drives the verdict. A supplied requirement is read as the target programme loudness in LUFS (defaulting to the EBU R 128 −23.0 LUFS), and the fiche passes when the integrated loudness is within the selected R 128 tolerance of it and the true peak is at or below −1.0 dBTP. Nothing else votes.

The tolerance switch, which is the one setting that changes whether a delivery passes. It follows the tolerance keyword: the default "qc" applies the ±0.2 LU measurement-error allowance of R 128 item i), for loudness workflows such as Quality Control, and "live" applies the ±1.0 LU tolerance of item h), which is permitted only where the Target Level is not practically achievable — a live programme, typically. The applied rule and its R 128 item are printed on the fiche, so a reader can always see which was used. The verdict is then evaluated on the loudness rounded to the displayed 0.1 LU, so the printed numbers can never contradict the verdict beside them.

Practicalities. Rendering needs reportlab and, for the figure the fiche embeds, matplotlib (pip install "phonometry[report,plot]"); only engine="reportlab" is supported. The fiche renders in English by default; pass language="es" for a Spanish fiche (translated fixed strings and a comma decimal separator), e.g. res.report("loudness_fiche_es.pdf", language="es").

from phonometry import broadcast, ReportMetadata
res = broadcast.program_loudness(x, fs) # a finished stereo programme
res.report(
"loudness_fiche.pdf",
metadata=ReportMetadata(
specimen="Reference tone sequence",
measurement_standard="EBU R 128",
laboratory="Phonometry Reference Laboratory",
requirement=-23.0, # target programme loudness (LUFS)
),
) # I (LUFS), LRA (LU), true peak (dBTP)

The example fiche is regenerated with make reports and kept rendered in the repository; click the preview to open the PDF.

EBU R 128 programme-loudness example report (PDF)

One-page programme-loudness compliance fiche: a metadata header, a four-column compliance table with the integrated loudness and maximum true peak carrying the verdict and the loudness range and momentary/short-term maxima as informational rows, the full-width loudness-vs-time plot, the boxed I = -23.0 LUFS (LRA = 10.0 LU, max TP = -20.4 dBTP) single-number result and a PASS verdict against the -23.0 LUFS target under the default ±0.2 LU QC tolerance of EBU R 128 item i).

Download the report (PDF)

Programme-loudness compliance fiche (ProgramLoudnessResult.report), I in LUFS with LRA in LU and true peak in dBTP.

Every synthesizable “minimum requirements” signal of EBU Tech 3341 (cases 1-6 and 9-23) and Tech 3342 (cases 1-4) runs in the test suite with its official tolerance (±0.1 LU for loudness, +0.2/−0.4 dB for true peak, ±1 LU for LRA), alongside the 997 Hz anchor and the closed-form under-read bound of Annex 2 Attachment 1. Cases 7-8 and the LRA cases 5-6 use authentic programme material distributed by the EBU and are not synthesizable; they run against the official EBU loudness test set (fetched from the EBU, whose licence covers technical testing only, so the audio is never committed) and all four pass within tolerance; the per-block loudness series measured from them are committed as plain data, so the gating and LRA stages of these cases also run everywhere without the audio. The independent pyloudnorm meter is a useful cross-check for real recordings; it was not used as a source for this implementation.

  • Covered

    ITU-R BS.1770-5 Annex 1: the K-weighting pre-filter of Tables 1-2, and the channel-weighted integrated loudness with its two-stage gate (Formulae 1-7, Table 3). k_weighting, k_weighting_coefficients and program_loudness implement these. Annex 2’s oversampled true-peak level runs through true_peak_level. Annex 3’s position-dependent channel weights for advanced sound systems run through channel_weight. EBU R 128’s −23.0 LUFS target and −1 dBTP ceiling sit on top of that. EBU Tech 3341’s momentary/short-term/integrated meter and EBU Tech 3342’s loudness range come from the same program_loudness result, with loudness_range() also standalone.

  • Not covered

    BS.1770-5 Annex 4, object-based audio, is out of scope. The guide notes that rendering to a loudspeaker layout has to happen first, and phonometry implements no spatial-audio renderer for that step. EBU Tech 3343 is cited only as production practice around these numbers: guidance, not an algorithm, and nothing here runs it. There is no normalisation helper either: section 7 shows the single gain, and applying it is one multiplication. And nothing on this page is a loudness model — K-weighting is a fixed linear filter, so masking, level dependence, critical bands and binaural summation are all outside it by construction; those live in Loudness (ISO 532).