Skip to content

Frequency Weighting (A, C, Z)

Standards: ISO 226IEC 61672Key references: Fletcher & Munson 1933

Frequency weighting curves simulate the human ear’s sensitivity. This guide covers A, C and Z, the curves specified by IEC 61672-1:2013: where they come from, how to apply them, the high_accuracy design and the Table 3 class verification. The rest of the family, the infrasound G curve, the historical B and D and the AU curve, is Special Weightings.

A, C and Z frequency weighting curves of IEC 61672-1 with a zoom showing the positive region of the A curve (+1.27 dB at 2.5 kHz)A, C and Z frequency weighting curves of IEC 61672-1 with a zoom showing the positive region of the A curve (+1.27 dB at 2.5 kHz)

The three curves of IEC 61672-1, measured through the library’s own filters at 48 kHz: A, which discards the bass; C, which keeps it; and Z, which weights nothing at all. The inset magnifies the small positive region of A around 2.5 kHz. The special B, D and AU curves have their own chart in Special Weightings, together with the infrasound G curve.

Show the code for this figure
import matplotlib.pyplot as plt
import numpy as np
from phonometry import filters
# Measure each curve's response: weight a centered unit impulse and take
# its spectrum (1 s buffer -> 1 Hz frequency resolution).
fs = 48000
impulse = np.zeros(fs)
impulse[fs // 2] = 1.0
freqs = np.fft.rfftfreq(fs, 1 / fs)
fig, ax = plt.subplots(figsize=(9, 5))
for curve in ("A", "C", "Z"):
spectrum = np.fft.rfft(filters.weighting_filter(impulse, fs, curve=curve))
ax.semilogx(freqs[1:], 20 * np.log10(np.abs(spectrum[1:]) + np.finfo(float).eps),
label=curve)
ax.set(xlim=(10, 22000), ylim=(-72, 15),
xlabel="Frequency [Hz]", ylabel="Response [dB]")
ax.grid(True, which="both", alpha=0.3)
ax.legend()
plt.show()
  • A-Weighting (A): Standard for environmental noise (IEC 61672-1).
  • C-Weighting (C): Used for peak sound pressure and high-level noise.
  • Z-Weighting (Z): flat by specification, not by omission. IEC 61672-1 defines Z as a nominally flat response from 10 Hz to 20 kHz with the same Table 3 tolerances as A and C, which is why verify_weighting_class can grade it at all. The library implements it as a bypass, so the effective bandwidth of a Z-weighted level is whatever your capture chain delivered: remove DC (detrend) and high-pass the wind noise yourself if the recording extends below 10 Hz.

The curve argument also accepts the four special weightings, charted and documented in Special Weightings: 'G' for infrasound (ISO 7196), the historical 'B' (ANSI S1.4-1983) and 'D' (IEC 537), and 'AU' for audible sound in the presence of ultrasound (IEC 61012).

The one-line answer is filters.weighting_filter(recording, fs, curve='A'). Section 2 shows it on a runnable signal, section 5 explains why the default design oversamples internally, and section 6 proves the result meets class 1.

The A and C curves are inverted equal-loudness contours, frozen into filters: A approximates the inverse of the historic 40-phon contour (quiet levels, where the ear discards bass most aggressively) and C the flatter ~100-phon one (loud levels). IEC 61672-1:2013 (Annex E) defines both analytically from four corner frequencies:

C is a band-pass with double poles at and (2 zeros at the origin); A adds the and poles (4 zeros), which is why it keeps falling through the low-mids. Both are normalized to exactly 0 dB at 1 kHz. Z applies no shaping inside the specified band; its design goal is 0 dB everywhere from 10 Hz to 20 kHz. The full pole/zero derivation is in the Theory page.

Equal-loudness contours per ISO 226 on the left, with the 40-phon contour highlighted; on the right the A-weighting curve overlaid on the inverted 40-phon contour, showing that A is the flipped contour frozen into a realizable filterEqual-loudness contours per ISO 226 on the left, with the 40-phon contour highlighted; on the right the A-weighting curve overlaid on the inverted 40-phon contour, showing that A is the flipped contour frozen into a realizable filter

The chain runs from Fletcher and Munson’s 1933 equal-loudness measurements to the first American sound level meter standard (1936), which gave meters switchable responses so the reading could approximate loudness at different levels: A from the 40-phon contour for quiet sounds, B from the ~70-phon contour for moderate ones, and a flat response for loud ones (the C curve proper, mirroring the flatter ~100-phon contour, arrived with the 1944 revision). Switching curves by level died in practice (readings jumped at the switch points, and field measurements became incomparable), but A survived alone: decades of hearing-damage and community-annoyance data had been collected with it, and it correlates with both about as well as far more elaborate metrics. IEC 61672-1 (first edition 2002) finished the cleanup: B was dropped, A and C were kept with tightened tolerances, and Z was introduced to replace the vaguely specified “linear” of older meters, which varied by manufacturer. The B curve (and the aircraft-noise D curve that met the same fate) remains available for historical data; see Special Weightings.

Because A discards bass and C keeps it, the difference is a one-number indicator of low-frequency content:

  • Below about 10 dB: an ordinary broadband spectrum; the A-weighted level rates it fairly.
  • Around 15 to 20 dB or more: the energy is concentrated at low frequencies (HVAC rumble, compressors, music bass through a wall). The A-weighted level then understates the problem; look at the octave spectrum, and below 20 Hz switch to the G curve.
  • Hearing-protector selection: the HML method of ISO 4869-2 keys on exactly this C-minus-A difference to decide how much low-frequency attenuation a protector must provide (the simpler SNR method sidesteps it by working from the C-weighted level directly).
import numpy as np
from phonometry import filters, signals
# A 50 Hz rumble under a light broadband hiss: quiet in A, loud in C.
fs = 48000
t = np.arange(10 * fs) / fs
rng = np.random.default_rng(1)
x = 0.2 * np.sin(2 * np.pi * 50 * t) + 0.01 * rng.standard_normal(t.size)
la = signals.leq(filters.weighting_filter(x, fs, curve="A"))
lc = signals.leq(filters.weighting_filter(x, fs, curve="C"))
print(f"LAeq = {la:.1f} dB LCeq = {lc:.1f} dB C - A = {lc - la:.1f} dB")
# LAeq = 52.4 dB LCeq = 75.7 dB C - A = 23.2 dB
# C - A above 20 dB: the A-weighted number alone would hide the rumble.
One-third-octave band levels of two signals, each drawn unweighted, A-weighted and C-weighted. On the left, a 50 Hz rumble under a light hiss: the unweighted and C-weighted curves coincide on a 77 dB spike in the 50 Hz band, while the A-weighted curve cuts that same band to 47 dB, and the box reads LAeq 52.4 dB, LCeq 75.7 dB, C minus A 23.2 dB. On the right, broadband pink noise: all three curves lie within a couple of decibels of each other from 1 kHz upward and the A curve falls away below it, and the box reads LAeq 54.6 dB, LCeq 56.4 dB, C minus A 1.8 dBOne-third-octave band levels of two signals, each drawn unweighted, A-weighted and C-weighted. On the left, a 50 Hz rumble under a light hiss: the unweighted and C-weighted curves coincide on a 77 dB spike in the 50 Hz band, while the A-weighted curve cuts that same band to 47 dB, and the box reads LAeq 52.4 dB, LCeq 75.7 dB, C minus A 23.2 dB. On the right, broadband pink noise: all three curves lie within a couple of decibels of each other from 1 kHz upward and the A curve falls away below it, and the box reads LAeq 54.6 dB, LCeq 56.4 dB, C minus A 1.8 dB

The same three band levels for two signals. A concentrated low-frequency source (left) puts almost all its energy where A cuts hardest, so C − A reaches 23 dB and the A-weighted level alone says nothing about it. Broadband pink noise (right) gives C − A = 1.8 dB, which is the regime the first bullet describes.

Show the code for this figure
import matplotlib.pyplot as plt
fig, ax = plt.subplots(figsize=(9, 4.5))
for curve, style in (("Z", "-"), ("C", "--"), ("A", ":")):
band_levels, centres = filters.octave_filter(
filters.weighting_filter(x, fs, curve=curve), fs, fraction=3)
ax.semilogx(centres, band_levels, style, label=f"{curve}-weighted bands")
ax.set(xlabel="Band centre frequency [Hz]", ylabel="Band level [dB]")
ax.legend()
plt.show()

A is a fixed filter derived from pure-tone equal-loudness data at one loudness level, and it is applied to complex spectra at every level. Three assumptions follow, and each breaks in a way worth naming. It says nothing about how bands combine, because the contours were measured with single tones; it is frozen at the 40-phon contour, so loud low-frequency sound is systematically under-rated as the real contours flatten with level; and it has no time structure at all, so an impulse and a steady sound of the same energy receive identical treatment.

Each of those has a proper instrument. For perceived loudness, use the ISO 532 models of Loudness rather than a different weighting curve. For content below 20 Hz, use the G curve of ISO 7196 (Special Weightings). For tonal or impulsive character, use the ISO 1996-1 adjustments (Environmental Levels). None of that makes A wrong to use: A-weighted levels are the quantity the limits are written in, so a report states the A-weighted level and adds the other metrics as supporting evidence, never as a substitute.

import numpy as np
from phonometry import filters
# recording: a calibrated microphone capture (Pa) — recorded through your measurement chain. Synthesized here so the guide runs standalone.
fs = 48000
recording = 0.2 * np.sin(2 * np.pi * 1000 * np.arange(fs) / fs)
# Apply A-weighting to the raw recording
weighted_signal = filters.weighting_filter(recording, fs, curve='A')
# Apply C-weighting for peak analysis
c_weighted_signal = filters.weighting_filter(recording, fs, curve='C')

The special weightings take the same curve argument; each is documented, with its own response chart, in Special Weightings.

The commonest desk task is not weighting a recording but A-weighting a table of one-third-octave band levels, and there are two routes that do not give the same answer. The exact one is what a meter does: weight the waveform, then band-filter it.

band_source = signals.noise_signal(fs, 20.0, color="pink", rms=0.05, seed=3)
weighted_bands, centres = filters.octave_filter(
filters.weighting_filter(band_source, fs, curve="A"), fs, fraction=3)
print(f"energy sum of the A-weighted bands "
f"{10 * np.log10(np.sum(10 ** (weighted_bands / 10))):.2f} dB")
print(f"LAeq of the same signal "
f"{signals.laeq(band_source, fs):.2f} dB")
# energy sum of the A-weighted bands 62.84 dB
# LAeq of the same signal 62.79 dB

The table route is the one you are forced into when the data arrives as band levels already: add the tabulated to each band level. It is an approximation, because A slopes steeply across the low bands, so its energy-weighted mean inside a band is not its value at the mid frequency. Measured against the exact route on the same pink noise: the two agree to better than 0.1 dB from 100 Hz to 8 kHz, the table route reads up to 0.7 dB low in the lowest bands (where A rises by about 12 dB per octave) and up to 1 dB high in the topmost band at 48 kHz (where A falls steeply and the band is clipped by Nyquist), while the totals agree to a few hundredths. So the error is not one-sided: it follows the sign of the curvature of the weighting across the band.

Report which route produced a weighted spectrum. The totals will agree; the band levels will not, and a band-by-band comparison between a meter’s A-weighted spectrum and a table-corrected one will show tenths of a decibel that are method, not measurement. The same argument applies unchanged to C and to G.

3. weighting_filter() / WeightingFilter parameters

Section titled “3. weighting_filter() / WeightingFilter parameters”
ParameterTypeUnitsRange / defaultNotes
x1D or 2D arrayanynon-empty2D is [channels, samples]
fsintHz> 0
curvestr'A' (default), 'B', 'C', 'D', 'G', 'AU', 'Z''G' per ISO 7196 (infrasound), 'B'/'D' historical and 'AU' per IEC 61012 are covered in Special Weightings; 'Z' is implemented as a bypass of a response the standard specifies as flat
high_accuracybooldefault True (function); class default None resolves to not statefulInternal oversampling keeps A/C in class 1 up to 16 kHz; details in §5
statefulbool (class only)default FalseCarries filter state across blocks (streaming)
steady_icbool (class only)default FalseSteady-state initial conditions (no onset transient)

If you weight many signals with the same parameters, design the filter once:

import numpy as np
from phonometry import filters
# recording: a calibrated microphone capture (Pa) — recorded through your measurement chain. Synthesized here so the guide runs standalone.
fs = 48000
recording = 0.2 * np.sin(2 * np.pi * 1000 * np.arange(fs) / fs)
wf = filters.WeightingFilter(fs, "A")
batch = [recording] # your batch of recordings
for recording in batch:
weighted = wf.filter(recording)

5. High-frequency accuracy (high_accuracy)

Section titled “5. High-frequency accuracy (high_accuracy)”

A plain bilinear-transform design compresses the response near Nyquist: at kHz the A-curve error at 12.5 kHz reaches −2.7 dB, outside the IEC 61672-1 class 1 tolerance (+2.0/−2.5 dB).

By default (high_accuracy=True), phonometry designs and runs the weighting filter at an internally oversampled rate (up to 8×, reaching ≥ 144 kHz at common audio rates; a 96 kHz input runs ×2) and decimates back, keeping the response within class 1 tolerances up to 16 kHz (error ≈ −0.5 dB at 12.5 kHz for kHz).

A-weighting high-frequency accuracy at 48 kHz: analytic curve versus plain bilinear versus oversampled design, with error subplotA-weighting high-frequency accuracy at 48 kHz: analytic curve versus plain bilinear versus oversampled design, with error subplot

The plain bilinear design (red) crosses the class 1 tolerance near 12.5 kHz; the oversampled design (blue) stays close to the analytic curve.

Show the code for this figure
import matplotlib.pyplot as plt
import numpy as np
from phonometry import filters
# Measured response of both designs at fs = 48 kHz: weight a centered
# unit impulse and take its spectrum...
fs = 48000
impulse = np.zeros(fs)
impulse[fs // 2] = 1.0
freqs = np.fft.rfftfreq(fs, 1 / fs)[1:]
# ...versus the analytic IEC 61672-1 A-curve built from the four corner
# frequencies of section 1, normalized to 0 dB at 1 kHz.
f1, f2, f3, f4 = 20.599, 107.653, 737.862, 12194.217
gain = (f4**2 * freqs**4) / ((freqs**2 + f1**2)
* np.sqrt((freqs**2 + f2**2) * (freqs**2 + f3**2))
* (freqs**2 + f4**2))
analytic = 20 * np.log10(gain / gain[np.argmin(np.abs(freqs - 1000))])
fig, ax = plt.subplots(figsize=(9, 5))
ax.semilogx(freqs, analytic, "k--", label="Analytic (IEC 61672-1)")
for high_accuracy, label in ((False, "Plain bilinear"),
(True, "Oversampled (default)")):
weighted = filters.weighting_filter(impulse, fs, curve="A",
high_accuracy=high_accuracy)
response = 20 * np.log10(np.abs(np.fft.rfft(weighted))
+ np.finfo(float).eps)[1:]
ax.semilogx(freqs, response, label=label)
ax.set(xlim=(1000, 20000), ylim=(-12, 3),
xlabel="Frequency [Hz]", ylabel="A-weighting response [dB]")
ax.grid(True, which="both", alpha=0.3)
ax.legend()
plt.show()
  • high_accuracy=False restores the legacy plain-bilinear behavior.
  • For 'G' the flag works like the others’: the default design is oversampled toward 48 kHz, which is what keeps infrasound rates accurate; high_accuracy=False runs the plain design at the input rate, costing about a decibel at 315 Hz at fs = 2000 and nothing at the 10 Hz reference.
  • Stateful (block) processing always uses the legacy design: the internal FIR resampling is incompatible with block continuity. Passing high_accuracy=True together with stateful=True raises a ValueError.
import numpy as np
from phonometry import filters
# recording: a calibrated microphone capture (Pa) — recorded through your measurement chain. Synthesized here so the guide runs standalone.
fs = 48000
recording = 0.2 * np.sin(2 * np.pi * 1000 * np.arange(fs) / fs)
# Explicit legacy behavior
y = filters.weighting_filter(recording, fs, curve="A", high_accuracy=False)
# Stateful block processing (legacy design, state carried between blocks)
wf = filters.WeightingFilter(fs, "A", stateful=True)
blocks = [recording] # your sequence of recording blocks
for block in blocks:
weighted = wf.filter(block)

See Block Processing for the streaming workflow and Theory for the analytic curve definitions.

6. Verifying against the tolerance tables (IEC 61672-1)

Section titled “6. Verifying against the tolerance tables (IEC 61672-1)”

verify_weighting_class checks a weighting filter against the acceptance limits of IEC 61672-1:2013 (Table 3). It evaluates the filter’s relative response at the exact base-10 frequency behind each nominal label below Nyquist (Table 3’s design goals are computed at , e.g. 15 848.9 Hz for “16 kHz”; IEC 61672-3 tests at the same frequencies), subtracts the design-goal weighting, and reports the performance class per frequency with its margin in dB. A dense logarithmic sweep additionally enforces subclause 5.5.7 between the nominal frequencies (the deviation from the analytic Annex E goal must stay within the larger of the two adjacent limits, so a resonance or notch between nominals cannot pass), and when Table 3 rows with finite lower limits fall beyond Nyquist the verdict is flagged range_limited (it then attests the checked frequencies only, not full 10 Hz-20 kHz conformance):

from phonometry import filters
result = filters.verify_weighting_class(filters.WeightingFilter(48000, "A"))
print(result["overall_class"]) # 1
print(result["range_limited"]) # False
print(result["between_nominals"]) # {'worst_freq': ..., 'margin_class1_db': ...}
print(result["bands"][20])
# {'freq': 1000.0, 'class': 1, 'deviation_db': 0.0, 'margin_class1_db': 0.7, 'margin_class2_db': 1.0}

The Table 3 acceptance mask itself is public too: weighting_class_limits(1) returns the 34 nominal frequencies with the lower/upper deviation limits (a lower limit of -inf means only the upper limit applies). The limits qualify the deviation from the design goal, so they are the same for A, C and Z.

A and C weighting deviations at 48 kHz threading within the IEC 61672-1 Table 3 class 1 acceptance corridor, with the wider class 2 limits dottedA and C weighting deviations at 48 kHz threading within the IEC 61672-1 Table 3 class 1 acceptance corridor, with the wider class 2 limits dotted

The oversampled A and C designs (blue, purple) stay near zero deviation, well inside the class 1 corridor (shaded); the wider class 2 limits are dotted. The corridor widens at the band extremes where only a one-sided limit applies.

Show the code for this figure
import matplotlib.pyplot as plt
import numpy as np
from phonometry import filters
freqs, lower1, upper1 = filters.weighting_class_limits(1)
_, lower2, upper2 = filters.weighting_class_limits(2)
lo1, lo2 = np.clip(lower1, -7, 7), np.clip(lower2, -7, 7)
fig, ax = plt.subplots(figsize=(10, 6.5))
ax.fill_between(freqs, lo1, upper1, step="mid", alpha=0.10,
label="Class 1 acceptance region")
ax.plot(freqs, upper1, drawstyle="steps-mid", label="Class 1 upper/lower limit")
ax.plot(freqs, lo1, drawstyle="steps-mid", color="C1")
ax.plot(freqs, upper2, ":", drawstyle="steps-mid", label="Class 2 upper/lower limit")
ax.plot(freqs, lo2, ":", drawstyle="steps-mid", color="C2")
for curve, marker in (("A", "o"), ("C", "s")):
bands = filters.verify_weighting_class(filters.WeightingFilter(48000, curve))["bands"]
f = [b["freq"] for b in bands]
dev = [b["deviation_db"] for b in bands]
ax.plot(f, dev, marker=marker, label=f"{curve} weighting deviation (48 kHz)")
ax.set(xscale="log", xlim=(10, 20000), ylim=(-7, 7),
xlabel="Frequency [Hz]", ylabel="Deviation from design goal [dB]")
ax.legend(fontsize=8, ncol=2)
plt.show()
  • Covered

    IEC 61672-1:2013 for the A, C and Z curves: the Annex E analytic definition from four corner frequencies, the high_accuracy design that keeps class 1 tolerances up to 16 kHz, and the Table 3 class 1/class 2 acceptance limits checked by verify_weighting_class.

  • Not covered

    The special curves, the infrasound G of ISO 7196, the historical B (ANSI S1.4-1983) and D (IEC 537) and the AU of IEC 61012, together with the verification of B and AU against their tolerance tables, have their own guide: Special Weightings.

How do I apply A-weighting to a signal in Python?

Section titled “How do I apply A-weighting to a signal in Python?”

Call filters.weighting_filter(recording, fs, curve='A') on a calibrated signal. It returns the A-weighted time signal, filtered with the pole-zero design of IEC 61672-1:2013 within class 1 tolerances, so signals.leq() on the output is the . The same function applies C, Z, B, D, AU and the infrasound G weighting through curve.

When should I use C-weighting instead of A-weighting?

Section titled “When should I use C-weighting instead of A-weighting?”

Use C-weighting for peak sound pressure and high-level noise, and use the difference as a low-frequency indicator: below about 10 dB the A-weighted level rates the spectrum fairly, while around 15 to 20 dB or more the energy is concentrated at low frequencies and the A-weighted level understates the problem. The HML method of ISO 4869-2 keys on exactly this C minus A difference for hearing-protector selection.

Is A-weighting accurate near 16 kHz at a 48 kHz sample rate?

Section titled “Is A-weighting accurate near 16 kHz at a 48 kHz sample rate?”

Not with a plain bilinear design: at kHz the A-curve error reaches −2.7 dB at 12.5 kHz, outside the IEC 61672-1 class 1 tolerance (+2.0/−2.5 dB). The default high_accuracy=True oversamples internally (up to 8×, reaching 144 kHz or more at common audio rates) and keeps the response within class 1 tolerances up to 16 kHz, with an error of about −0.5 dB at 12.5 kHz.

  • Fletcher, H., & Munson, W. A. (1933). Loudness, its definition, measurement and calculation. The Journal of the Acoustical Society of America, 5(2), 82-108. https://doi.org/10.1121/1.1915637The original equal-loudness measurements whose 40-phon contour the A-curve inverts (section 1).
  • International Electrotechnical Commission. (2013). Electroacoustics — Sound level meters — Part 1: Specifications (IEC 61672-1:2013). The normative A, C and Z frequency-weighting curves (the Annex E analytic definition from four corner frequencies, normalized to 0 dB at 1 kHz), the class 1 tolerances the high_accuracy design keeps up to 16 kHz, and the Table 3 class 1/class 2 acceptance limits checked by verify_weighting_class in section 6.
  • International Organization for Standardization. (2023). Acoustics — Normal equal-loudness-level contours (ISO 226:2023). The modern successors of the Fletcher-Munson curves, drawn in the diagram of section 1.