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.
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 npfrom phonometry import broadcast
fs = 48000t = np.arange(20 * fs) / fsx = np.zeros((5, t.size)) # L, R, C, Ls, Rsx[0] = np.sin(2 * np.pi * 997.0 * t) # 0 dB FS on the left channelprint(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.01print(round(broadcast.integrated_loudness(np.vstack([mono, mono]), fs), 2)) # 0.00Channels are summed, not averaged
Section titled “Channels are summed, not averaged”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 npfrom 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 itselfk_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:
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 pltfrom 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.
2. Gating and the programme loudness
Section titled “2. Gating and the programme loudness”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 npfrom phonometry import broadcast
fs = 48000def 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 LUFSres.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.
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.
The figure makes the gate visible on a shaped-noise programme with a long quiet tail:
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 pltimport numpy as npfrom scipy import signalfrom phonometry import broadcast
fs = 48000rng = 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 npfrom phonometry import broadcast
fs = 48000def 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.0res.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:
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 pltimport numpy as npfrom scipy import signalfrom phonometry import broadcast
fs = 48000rng = 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()4. Loudness range (EBU Tech 3342)
Section titled “4. Loudness range (EBU Tech 3342)”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 npfrom phonometry import broadcast
fs = 48000def 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 LUres.plot() # the shaded LRA band spans the P10-P95 spread (needs matplotlib)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 pltimport numpy as npfrom phonometry import broadcast
fs = 48000def 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 plateausplt.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.
5. True peak (Annex 2)
Section titled “5. True peak (Annex 2)”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 npfrom phonometry import broadcast
fs = 48000t = 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.01print(round(float(broadcast.true_peak_level(x, fs)), 2)) # 0.12The 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).
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 dBFSaxl.axhline(1.0, linestyle="--") # the true excursionaxl.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.
6. Multichannel programmes and Annex 3
Section titled “6. Multichannel programmes and Annex 3”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:
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.
7. Normalising to the target
Section titled “7. Normalising to the target”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.integratedprint(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.02print(round(after.true_peak, 2)) # -9.96, was -10.82print(after.true_peak <= -1.0) # the R 128 ceiling: TrueBecause 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.
8. EBU R 128 report (.report())
Section titled “8. EBU R 128 report (.report())”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 programmeres.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.

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).
9. Validation
Section titled “9. Validation”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.
What this guide covers
Section titled “What this guide covers”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_coefficientsandprogram_loudnessimplement these. Annex 2’s oversampled true-peak level runs throughtrue_peak_level. Annex 3’s position-dependent channel weights for advanced sound systems run throughchannel_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 sameprogram_loudnessresult, withloudness_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).
See also
Section titled “See also”- Loudness (ISO 532): the psychoacoustic models, in sones and specific loudness, that answer the perceived-magnitude question this page’s gated LUFS deliberately does not.
- Integrated & Statistical Levels:
, and the same oversampled-peak machinery behind
lc_peak. - Frequency weightings: A, C and Z against the K-weighting of this page.
- Electroacoustics (IEC 60268-3): the chain that carries the programme once it has been normalised.
- Broadcast: the section overview.
- API reference:
broadcast.
References
Section titled “References”- European Broadcasting Union. (2023). Guidelines for production of programmes in accordance with EBU R 128 (EBU Tech 3343). The production practice around the numbers on this page.
- European Broadcasting Union. (2023). Loudness metering: 'EBU Mode' metering to supplement loudness normalisation (EBU Tech 3341). The EBU Mode momentary/short-term/integrated (M/S/I) time scales, validated against the synthesizable Table 1 minimum-requirements test signals with their official tolerances.
- European Broadcasting Union. (2023). Loudness normalisation and permitted maximum level of audio signals (EBU R 128). The −23.0 LUFS target level, the −1 dBTP ceiling and the normalisation practice.
- European Broadcasting Union. (2023). Loudness range: A measure to supplement loudness normalisation (EBU Tech 3342). The loudness range (LRA) algorithm, its reference implementation and its test signals, validated against its Table 1 signals at ±1 LU.
- International Telecommunication Union. (2023). Algorithms to measure audio programme loudness and true-peak audio level (Recommendation ITU-R BS.1770-5 (11/2023)). The K-weighting pre-filter (Annex 1, Tables 1-2), the channel-weighted loudness and two-stage gating (Annex 1, Formulae 1-7, Table 3), the true-peak estimation guidelines (Annex 2 and its Attachment 1 under-read bound) and the position-dependent channel weights for advanced sound systems (Annex 3, Tables 4-5).
- Steinmetz, C. J., & Reiss, J. D. (2021). pyloudnorm: A simple yet flexible loudness meter in Python. 150th AES Convention. An independent BS.1770 implementation, useful as a cross-check.