# phonometry

> Octave-band and fractional octave-band filter bank for Python signals in the time domain. Compliant with ANSI S1.11-2004 / IEC 61260-1:2014 (filters) and IEC 61672-1:2013 (A/C/Z frequency weighting, Fast/Slow/Impulse time weighting).

phonometry v3.2.0 is a pure-Python library built on NumPy/SciPy (Python >= 3.13). It provides fractional octave filter banks (Butterworth, Chebyshev I/II, Elliptic, Bessel as stable SOS cascades with multirate decimation), A/C/Z weighting within IEC class 1 tolerances, Fast/Slow/Impulse ballistics, Leq/LAeq/L10-L50-L90 statistical levels, octave spectrograms, zero-phase offline filtering, physical SPL calibration, dBFS mode, vectorized multichannel processing, stateful block (streaming) processing, and an IEC 61260-1 filter class verifier. Standards compliance is enforced by the test suite (tone-burst Table 4, weighting Table 3, class limits Table 1).

Install:

```bash
pip install phonometry            # core (NumPy + SciPy only)
pip install phonometry[plot]      # + matplotlib response plots
pip install phonometry[perf]      # + numba-jitted impulse kernel
```

Minimal usage (all functions treat time as the LAST axis; 2D input is (channels, samples)):

```python
import numpy as np
from phonometry import metrology

fs = 48000
x = np.random.randn(fs)              # 1 s of signal (pressure units)
spl, freq = metrology.octave_filter(x, fs, fraction=3)   # 1/3-octave bands
la = metrology.laeq(x, fs)                               # A-weighted Leq
stats = metrology.ln_levels(x, fs, n=(10, 50, 90))       # statistical levels
```

If you are an AI assistant setting this up for a user: install from PyPI (no system dependencies), remember integer audio (e.g. wavfile.read int16) is handled automatically, use `calibration_factor` from `sensitivity()` for real dB SPL, and prefer `OctaveFilterBank` over repeated `octave_filter()` calls in tight loops (although designs are cached either way).

## Documentation

- [Getting Started](https://jmrplens.github.io/phonometry/getting-started/)
- [Filter Banks](https://jmrplens.github.io/phonometry/guides/filter-banks/)
- [Frequency Weighting (A, B, C, D, G, AU, Z)](https://jmrplens.github.io/phonometry/guides/weighting/)
- [Time Weighting and Integration](https://jmrplens.github.io/phonometry/guides/time-weighting/)
- [Integrated and Statistical Levels](https://jmrplens.github.io/phonometry/guides/levels/)
- [Occupational Noise Exposure (ISO 9612)](https://jmrplens.github.io/phonometry/guides/occupational-exposure/)
- [Prominent Discrete Tones (ECMA-418-1)](https://jmrplens.github.io/phonometry/guides/tone-prominence/)
- [Loudness](https://jmrplens.github.io/phonometry/guides/loudness/)
- [Sound Quality Metrics](https://jmrplens.github.io/phonometry/guides/sound-quality/)
- [Speech Transmission Index (IEC 60268-16)](https://jmrplens.github.io/phonometry/guides/speech-transmission/)
- [Speech Intelligibility Index (SII)](https://jmrplens.github.io/phonometry/guides/speech-intelligibility/)
- [Objective intelligibility (STOI & ESTOI)](https://jmrplens.github.io/phonometry/guides/objective-intelligibility/)
- [Sound Intensity (p-p method)](https://jmrplens.github.io/phonometry/guides/intensity/)
- [Room Acoustics](https://jmrplens.github.io/phonometry/guides/room-acoustics/)
- [Image sources and the steady-state room field (Kuttruff / Vorländer / Bies)](https://jmrplens.github.io/phonometry/guides/room-image-sources/)
- [Field Insulation Measurement and Ratings](https://jmrplens.github.io/phonometry/guides/insulation-field/)
- [Laboratory Insulation Measurement](https://jmrplens.github.io/phonometry/guides/insulation-lab/)
- [Predicting Sound Insulation (EN 12354)](https://jmrplens.github.io/phonometry/guides/insulation-prediction/)
- [Predicting Panel Sound Insulation (mass law, coincidence, double walls)](https://jmrplens.github.io/phonometry/guides/panel-sound-insulation/)
- [Outdoor Sound Propagation](https://jmrplens.github.io/phonometry/guides/outdoor-propagation/)
- [Spherical ground effect and advanced barriers (Attenborough / Salomons / Bies)](https://jmrplens.github.io/phonometry/guides/ground-barriers/)
- [Sound Power](https://jmrplens.github.io/phonometry/guides/sound-power/)
- [Calibration and dBFS](https://jmrplens.github.io/phonometry/guides/calibration/)
- [Data qualification: stationarity, level crossings and peaks (Bendat & Piersol)](https://jmrplens.github.io/phonometry/guides/data-qualification/)
- [Calibrated spectral analysis (Bendat & Piersol)](https://jmrplens.github.io/phonometry/guides/spectral-analysis/)
- [Multiple and partial coherence (Bendat & Piersol)](https://jmrplens.github.io/phonometry/guides/miso-coherence/)
- [Correlation, time delay and envelope (Bendat & Piersol / Knapp & Carter)](https://jmrplens.github.io/phonometry/guides/correlation-delay/)
- [Test signals and sample-rate tools (IEC 60268-1)](https://jmrplens.github.io/phonometry/guides/test-signals/)
- [Cepstrum, echoes and the envelope spectrum (Havelock / Bendat & Piersol)](https://jmrplens.github.io/phonometry/guides/cepstrum-echoes/)
- [Time synchronous averaging (McFadden 1987)](https://jmrplens.github.io/phonometry/guides/synchronous-averaging/)
- [Swept-sine distortion and phase utilities (Farina / Novak)](https://jmrplens.github.io/phonometry/guides/swept-sine-distortion/)
- [Block Processing](https://jmrplens.github.io/phonometry/guides/block-processing/)
- [Multichannel and Performance](https://jmrplens.github.io/phonometry/guides/multichannel/)
- [API Reference](https://jmrplens.github.io/phonometry/reference/api/)
- [Theoretical Background](https://jmrplens.github.io/phonometry/reference/theory/)
- [Theory: Signal Analysis](https://jmrplens.github.io/phonometry/reference/theory/signal-analysis/)
- [Theory: Perception and Hearing](https://jmrplens.github.io/phonometry/reference/theory/perception/)
- [Theory: Rooms and Buildings](https://jmrplens.github.io/phonometry/reference/theory/rooms-buildings/)
- [Theory: Materials and Surfaces](https://jmrplens.github.io/phonometry/reference/theory/materials-surfaces/)
- [Theory: Environment and Transport](https://jmrplens.github.io/phonometry/reference/theory/environment-transport/)
- [Theory: Vibration](https://jmrplens.github.io/phonometry/reference/theory/vibration/)
- [Why phonometry](https://jmrplens.github.io/phonometry/reference/why-phonometry/)

## Source and metadata

- [Repository](https://github.com/jmrplens/phonometry)
- [PyPI](https://pypi.org/project/phonometry/)
- [Changelog](https://github.com/jmrplens/phonometry/blob/main/CHANGELOG.md)
- [Cite this software](https://github.com/jmrplens/phonometry/blob/main/CITATION.cff)


---


<!-- source: docs/getting-started.md | canonical: https://jmrplens.github.io/phonometry/getting-started/ -->

# Getting Started

## Installation

**Option 1: From PyPI (Recommended)**

```bash
pip install phonometry
```

Optional extras:

```bash
pip install phonometry[plot]    # matplotlib, for filter response plots and result .plot() methods
pip install phonometry[perf]    # numba, faster 'impulse' time weighting
pip install phonometry[report]  # reportlab + svglib, so result .report() methods render normative PDF fiches
pip install phonometry[full]    # all of the above
```

**Option 2: Cloning and Installing**

```bash
git clone https://github.com/jmrplens/phonometry.git
cd phonometry
pip install .
```

**Option 3: Git Submodule**

```bash
git submodule add https://github.com/jmrplens/phonometry.git
# Then install in editable mode to use it from your project
pip install -e ./phonometry
```

## The processing chain at a glance

Every phonometry analysis is some subset of one pipeline: take the raw
signal, convert it to physical units, weight it in frequency, split it into
standardized bands, smooth it in time and reduce it to metrics:

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/diagram_signal_chain_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/diagram_signal_chain.svg" alt="phonometry processing chain: signal, calibration, frequency weighting, octave filter bank, time weighting and metrics, with the standard verified at each stage" width="92%"></picture>

Each stage is an independent function or class you can use on its own; the
guides cover them left to right ([Calibration](https://jmrplens.github.io/phonometry/guides/calibration/) →
[Frequency Weighting](https://jmrplens.github.io/phonometry/guides/weighting/) → [Filter Banks](https://jmrplens.github.io/phonometry/guides/filter-banks/) →
[Time Weighting](https://jmrplens.github.io/phonometry/guides/time-weighting/) → [Levels](https://jmrplens.github.io/phonometry/guides/levels/)).

## Basic Usage: 1/3 Octave Analysis

Analyze a signal and get the Sound Pressure Level (SPL) per frequency band.

```python
import numpy as np
from phonometry import metrology

fs = 48000
t = np.linspace(0, 1, fs, endpoint=False)
# Composite signal: 100Hz + 1000Hz
signal = np.sin(2 * np.pi * 100 * t) + np.sin(2 * np.pi * 1000 * t)

# Apply 1/3 octave filter bank
spl, freq = metrology.octave_filter(signal, fs=fs, fraction=3)

print(f"Bands: {freq}")
# Bands: [12.589254117941678, 15.848931924611138, ..., 19952.623149688785]  (33 bands)
print(f"SPL [dB]: {spl}")
# SPL [dB]: [46.88395351 47.96774897 49.04991279 ...]  — ~90.7 dB at 100 Hz and ~90.9 dB at 1 kHz
```

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/signal_response_fraction_3_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/signal_response_fraction_3.svg" alt="One-third-octave spectrum analysis of a multi-tone signal with the raw PSD in the background" width="80%"></picture>

*Example of a 1/3 Octave Band spectrum analysis of a complex signal.*

<details>
<summary>Show the code for this figure</summary>

```python
import matplotlib.pyplot as plt
import scipy.signal
import numpy as np
from phonometry import metrology

fs = 48000
t = np.linspace(0, 1, fs, endpoint=False)
# Composite signal: 100Hz + 1000Hz
signal = np.sin(2 * np.pi * 100 * t) + np.sin(2 * np.pi * 1000 * t)
# Apply 1/3 octave filter bank
spl, freq = metrology.octave_filter(signal, fs=fs, fraction=3)

# Gray background: the raw-signal PSD (Welch), shifted to sit just below the
# band SPLs so both spectral shapes share one axis.
f_psd, psd = scipy.signal.welch(signal, fs, nperseg=8192)
psd_db = 10 * np.log10(psd + 1e-12)
psd_db += np.max(spl) - np.max(psd_db) - 5

fig, ax = plt.subplots()
ax.semilogx(f_psd, psd_db, color="gray", alpha=0.6, label="Raw signal PSD")
ax.semilogx(freq, spl, marker="o", markerfacecolor="white",
            label="1/3 octave bands")
ax.set_xlabel("Frequency [Hz]")
ax.set_ylabel("SPL [dB]")
ax.legend()
plt.show()
```

</details>

## Analyzing an audio file

```python
from scipy.io import wavfile
from phonometry import metrology

# Load standard WAV file
fs, signal = wavfile.read("measurement.wav")

# Analyze
# Note: To obtain real-world SPL values, you must calibrate the input.
# See the Calibration guide.
spl, freq = metrology.octave_filter(signal, fs=fs, fraction=3)
```

Integer audio (e.g. int16 WAV data) is converted to float64 internally, so it is
safe to pass `wavfile.read` output directly.

## Where to go next

The octave analysis above uses the `metrology` core, one of fifteen domain
namespaces; the documentation index walks through the rest, from
psychoacoustics and room, building and vibration acoustics to environmental,
aircraft and underwater noise, electroacoustics and FDTD wave simulation.
Every result object exposes a one-line `.plot(language="en"|"es")` figure and,
where a standard defines a reporting format, a `.report()` method that renders
the normative PDF fiche.

- [Filter Banks](https://jmrplens.github.io/phonometry/guides/filter-banks/): choose an architecture and inspect responses
- [Calibration and dBFS](https://jmrplens.github.io/phonometry/guides/calibration/): get real-world SPL values
- [Why phonometry](https://jmrplens.github.io/phonometry/reference/why-phonometry/): the conformance-first design philosophy
- [Conformance report](CONFORMANCE.md): the expected and computed value of all 371 checks
- [API Reference](https://jmrplens.github.io/phonometry/reference/api/): every parameter of every function
- [Bibliography](references.md): the books and papers behind every guide, each with a verified link

---


<!-- source: docs/filter-banks.md | canonical: https://jmrplens.github.io/phonometry/guides/filter-banks/ -->

# Filter Banks

phonometry supports several filter types, each with its own transfer function
characteristic. All banks place their **−3 dB points on the ANSI S1.11 band
edges**, so band levels are comparable across architectures.

## 1. Fractional octave bands: the math

IEC 61260-1:2014 builds every band from the base-10 octave ratio
$G = 10^{3/10} \approx 1.99526$ (so "one octave" is *not* exactly 2). For
band fraction $1/b$, the mid frequencies and band edges follow (5.2-5.5):

$$
f_m = 1000 \cdot G^{x/b} \quad (b\ \text{odd}), \qquad
f_1 = f_m G^{-1/2b}, \quad f_2 = f_m G^{+1/2b}
$$

so every 1/3-octave band spans $G^{1/3} \approx 1.2589 \approx 10^{1/10}$:
ten bands per decade, which is why the nominal frequencies (25, 31.5, 40 …)
repeat scaled by 10. phonometry designs each band as an SOS cascade whose
−3 dB points land exactly on $f_1$ and $f_2$ for every architecture; for
Chebyshev II, Elliptic and Bessel that requires pre-warping the analytic
band-edge mapping rather than trusting SciPy's default parametrization.

### Poles, zeros and stability

A digital band-pass filter is a constellation of poles and zeros in the
z-plane: zeros at or near DC and Nyquist pin the response down far from the
band (to the stopband floor, for equiripple designs), and the poles cluster
just inside the unit circle at the angles
$\omega = 2\pi f / f_s$ the passband spans. Two intuitions follow. First,
selectivity is proximity: the closer the poles sit to the unit circle, the
sharper the band and the longer the filter rings (the group-delay peaks of
section 8 are that ringing, measured). Second, stability is a margin, not a
property of the architecture: an IIR filter is stable only while every pole
stays strictly inside the unit circle, and a narrow band at a high sample
rate pushes the poles outward (pole radius $\approx 1 - \pi B / f_s$ for
bandwidth $B$) and squeezes them together, until double-precision
coefficients can no longer represent their positions accurately.
Second-order sections (SOS) defuse half of the problem: each pole pair keeps
its own coefficients, so rounding errors stay local instead of compounding
through one high-order polynomial. The other half, the tiny $B / f_s$ ratio
itself, is what decimation fixes.

### Multirate decimation

A 25 Hz one-third-octave band at 48 kHz spans about 5.8 Hz, 0.024 %
of Nyquist, with coefficients so stiff they go numerically unstable. The bank
avoids that by filtering low bands at a decimated rate:

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/diagram_multirate_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/diagram_multirate.svg" alt="Multirate decimation: high bands filtered at the input rate, low bands after anti-alias low-pass and decimation so the SOS sections stay numerically healthy" width="92%"></picture>

Decimating by $M$ rescales the problem: the same 5.8 Hz bandwidth becomes
$M$ times larger relative to the new Nyquist, the pole radius pulls away from
the unit circle, and the SOS coefficients return to a well-conditioned range.
The price is bookkeeping the bank pays internally: an anti-alias low-pass must
run before every decimation stage, because a component above the new Nyquist
that folds down lands *inside* the low bands being measured, and no later
filter can remove it.

### Aliasing pitfalls

The bank protects its own decimation stages, but it can only analyze what the
capture chain delivered:

- **Fold-down at the ADC.** Energy above $f_s/2$ that reaches the converter
  without an analog anti-alias filter folds into the analysis range and is
  indistinguishable from real in-band sound. Sound cards filter this
  internally; custom instrumentation chains may not.
- **Cheap resampling.** Converting a 44.1 kHz recording to 48 kHz with a
  low-quality resampler leaves images that bias the highest bands. Use a
  polyphase resampler (`scipy.signal.resample_poly`) or, simpler, analyze at
  the native rate: every phonometry function takes `fs` directly.
- **Bands near Nyquist.** A band whose upper edge approaches $f_s/2$ cannot
  realize its design response: the bilinear transform compresses the
  frequency axis there (the same effect the weighting filters counter with
  `high_accuracy`). Keep the top band edge comfortably below Nyquist or raise
  `fs`, and let `verify_filter_class` report how much margin is left.

## 2. Filter Comparison and Zoom

We use Second-Order Sections (SOS) for all filters to ensure numerical stability.
The following plot compares the architectures focusing on the -3 dB crossover point.

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/filter_type_comparison_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/filter_type_comparison.svg" alt="Magnitude response comparison of the five filter architectures for the 1 kHz octave band, with a zoom at the -3 dB crossover" width="80%"></picture>

<details>
<summary>Show the code for this figure</summary>

```python
import matplotlib.pyplot as plt
import numpy as np
from scipy.signal import sosfreqz
from phonometry import metrology

fs = 48000
fig, ax = plt.subplots(figsize=(9, 5))
for ftype in ("butter", "cheby1", "cheby2", "ellip", "bessel"):
    # limits picks out the single 1 kHz octave band
    bank = metrology.OctaveFilterBank(fs, fraction=1, order=6, limits=[800, 1200],
                            filter_type=ftype)
    idx = int(np.argmin(np.abs(np.array(bank.freq) - 1000)))
    fsd = fs / bank.factor[idx]           # rate the band actually runs at
    w, h = sosfreqz(bank.sos[idx], worN=16384, fs=fsd)
    ax.semilogx(w, 20 * np.log10(np.abs(h) + 1e-9), label=ftype)
ax.axhline(-3, color="gray", linestyle=":", label="-3 dB")
ax.set(xlim=(100, 8000), ylim=(-80, 5),
       xlabel="Frequency [Hz]", ylabel="Magnitude [dB]")
ax.grid(True, which="both", alpha=0.3)
ax.legend()
plt.show()
```

</details>

| Type | Name | Usage Example | Best For |
| :--- | :--- | :--- | :--- |
| `butter` | **Butterworth** | `octave_filter(x, fs, filter_type='butter')` | General acoustic measurement. |
| `cheby1` | **Chebyshev I** | `octave_filter(x, fs, filter_type='cheby1', ripple=0.1)` | Sharper roll-off at the cost of ripple. |
| `cheby2` | **Chebyshev II** | `octave_filter(x, fs, filter_type='cheby2')` | Flat passband with stopband zeros. |
| `ellip` | **Elliptic** | `octave_filter(x, fs, filter_type='ellip', ripple=0.1)` | Maximum selectivity. |
| `bessel` | **Bessel** | `octave_filter(x, fs, filter_type='bessel')` | Preserving transient waveform shapes. |

## 3. `octave_filter()` / `OctaveFilterBank` parameters

| Parameter | Type | Units | Range / default | Notes |
| :--- | :--- | :--- | :--- | :--- |
| `x` | 1D or 2D array | digital units | non-empty | 2D is `[channels, samples]` |
| `fs` | int | Hz | > 0 | |
| `fraction` | int | — | default `1`; common `3`; any `b ≥ 1` | Bands per octave = `b` |
| `order` | int | — | default `6` | SOS order per band |
| `limits` | list `[lo, hi]` | Hz | default `[12, 20000]` | Analysis range |
| `filter_type` | str | — | `'butter'` (default), `'cheby1'`, `'cheby2'`, `'ellip'`, `'bessel'` | See comparison above |
| `ripple` / `attenuation` | float | dB | `ripple` default `0.1`; `attenuation` default `72.0` | Passband ripple / stopband attenuation (cheby/ellip); `cheby2` needs `attenuation ≥ 70` for class 1, since scipy pins its equiripple floor at exactly this value |
| `show` | bool | — | default `False` | Plot the bank response (needs matplotlib) |
| `sigbands` | bool | — | default `False` | Also return the per-band time signals |
| `mode` | str | — | `'rms'` (default), `'peak'`, `'sum'` | Per-band statistic returned |
| `nominal` | bool | — | default `False` | Return nominal band labels (e.g. `1000`) instead of exact centre frequencies |
| `detrend` | bool | — | default `True` | Remove each band's DC offset before the level (improves low-frequency accuracy) |
| `calibration_factor` | float | — | default `1.0` | Scales the input to pascals (see the Calibration guide) |
| `dbfs` | bool | — | default `False` | Reference levels to digital full scale instead of 20 µPa |
| `plot_file` | str or `None` | — | default `None` | Save the bank-response plot to this path |
| `zero_phase` | bool | — | default `False` | Forward-backward filtering (offline) |
| `stateful` / `steady_ic` (class) | bool | — | default `False` | Streaming state; see [Block Processing](https://jmrplens.github.io/phonometry/guides/block-processing/) |

`verify_filter_class(bank)` checks the designed bank against the IEC 61260-1
Table 1 acceptance limits and reports the class (`1`, `2` or `None` if outside both) with per-band
margins.

## 4. Gallery of Filter Bank Responses

Full spectral view of the filter banks for Octave (1/1) and 1/3-Octave fractions.

| Architecture | 1/1 Octave (Fraction=1) | 1/3 Octave (Fraction=3) |
| :--- | :--- | :--- |
| **Butterworth** | <picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/filter_butter_fraction_1_order_6_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/filter_butter_fraction_1_order_6.svg" alt="Butterworth octave-band filter bank frequency response" width="100%"></picture> | <picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/filter_butter_fraction_3_order_6_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/filter_butter_fraction_3_order_6.svg" alt="Butterworth one-third-octave filter bank frequency response" width="100%"></picture> |
| **Chebyshev I** | <picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/filter_cheby1_fraction_1_order_6_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/filter_cheby1_fraction_1_order_6.svg" alt="Chebyshev I octave-band filter bank frequency response" width="100%"></picture> | <picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/filter_cheby1_fraction_3_order_6_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/filter_cheby1_fraction_3_order_6.svg" alt="Chebyshev I one-third-octave filter bank frequency response" width="100%"></picture> |
| **Chebyshev II** | <picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/filter_cheby2_fraction_1_order_6_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/filter_cheby2_fraction_1_order_6.svg" alt="Chebyshev II octave-band filter bank frequency response" width="100%"></picture> | <picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/filter_cheby2_fraction_3_order_6_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/filter_cheby2_fraction_3_order_6.svg" alt="Chebyshev II one-third-octave filter bank frequency response" width="100%"></picture> |
| **Elliptic** | <picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/filter_ellip_fraction_1_order_6_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/filter_ellip_fraction_1_order_6.svg" alt="Elliptic octave-band filter bank frequency response" width="100%"></picture> | <picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/filter_ellip_fraction_3_order_6_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/filter_ellip_fraction_3_order_6.svg" alt="Elliptic one-third-octave filter bank frequency response" width="100%"></picture> |
| **Bessel** | <picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/filter_bessel_fraction_1_order_6_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/filter_bessel_fraction_1_order_6.svg" alt="Bessel octave-band filter bank frequency response" width="100%"></picture> | <picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/filter_bessel_fraction_3_order_6_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/filter_bessel_fraction_3_order_6.svg" alt="Bessel one-third-octave filter bank frequency response" width="100%"></picture> |

<details>
<summary>Show the code for this figure</summary>

```python
from phonometry import metrology

# One figure per architecture and fraction: the whole response gallery
fs = 48000
for ftype in ("butter", "cheby1", "cheby2", "ellip", "bessel"):
    for fraction in (1, 3):
        # show=True draws the bank's frequency response
        metrology.OctaveFilterBank(fs=fs, fraction=fraction, order=6,
                                   limits=[12, 20000], filter_type=ftype,
                                   show=True)
```

</details>

## 5. Filter Usage and Examples

### 1. Butterworth (`butter`)

The Butterworth filter is known for its **maximally flat passband**. It is the
standard choice for acoustic measurements where no ripple is allowed within the
frequency bands.

```python
import numpy as np
from phonometry import metrology

# A calibrated signal in Pa so the guide runs standalone
fs = 48000
x = 0.2 * np.sin(2 * np.pi * 1000 * np.arange(fs) / fs)

# Default standard measurement
spl, freq = metrology.octave_filter(x, fs, filter_type='butter')
```

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/filter_butter_fraction_3_order_6_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/filter_butter_fraction_3_order_6.svg" alt="Butterworth one-third-octave filter bank frequency response" width="60%"></picture>

<details>
<summary>Show the code for this figure</summary>

```python
from phonometry import metrology

# Draw this bank's response (1/3 octave, order 6, Butterworth)
metrology.OctaveFilterBank(fs=48000, fraction=3, order=6, limits=[12, 20000],
                           filter_type='butter', show=True)
```

</details>

### 2. Chebyshev I (`cheby1`)

Chebyshev Type I filters provide a **steeper roll-off** than Butterworth at the
expense of ripples in the passband. Useful when high selectivity is needed near
the cut-off frequencies.

```python
import numpy as np
from phonometry import metrology

# A calibrated signal in Pa so the guide runs standalone
fs = 48000
x = 0.2 * np.sin(2 * np.pi * 1000 * np.arange(fs) / fs)

# Selectivity with 0.1 dB passband ripple
spl, freq = metrology.octave_filter(x, fs, filter_type='cheby1', ripple=0.1)
```

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/filter_cheby1_fraction_3_order_6_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/filter_cheby1_fraction_3_order_6.svg" alt="Chebyshev I one-third-octave filter bank frequency response" width="60%"></picture>

<details>
<summary>Show the code for this figure</summary>

```python
from phonometry import metrology

# Draw this bank's response (1/3 octave, order 6, Chebyshev I)
metrology.OctaveFilterBank(fs=48000, fraction=3, order=6, limits=[12, 20000],
                           filter_type='cheby1', ripple=0.1, show=True)
```

</details>

### 3. Chebyshev II (`cheby2`)

Also known as Inverse Chebyshev, it has a **flat passband** and ripples in the
stopband. It provides faster roll-off than Butterworth without affecting the
signal in the passband. The stopband edges are placed automatically so that the
−3 dB points land on the band edges (`attenuation` must be > 3.01 dB).

```python
import numpy as np
from phonometry import metrology

# A calibrated signal in Pa so the guide runs standalone
fs = 48000
x = 0.2 * np.sin(2 * np.pi * 1000 * np.arange(fs) / fs)

# Flat passband, class-1 default 72 dB stopband attenuation
spl, freq = metrology.octave_filter(x, fs, filter_type='cheby2')
```

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/filter_cheby2_fraction_3_order_6_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/filter_cheby2_fraction_3_order_6.svg" alt="Chebyshev II one-third-octave filter bank frequency response" width="60%"></picture>

<details>
<summary>Show the code for this figure</summary>

```python
from phonometry import metrology

# Draw this bank's response (1/3 octave, order 6, Chebyshev II)
metrology.OctaveFilterBank(fs=48000, fraction=3, order=6, limits=[12, 20000],
                           filter_type='cheby2', show=True)
```

</details>

### 4. Elliptic (`ellip`)

Elliptic (Cauer) filters have the **shortest transition width** (steepest
roll-off) for a given order. They feature ripples in both the passband and stopband.

```python
import numpy as np
from phonometry import metrology

# A calibrated signal in Pa so the guide runs standalone
fs = 48000
x = 0.2 * np.sin(2 * np.pi * 1000 * np.arange(fs) / fs)

# Maximum selectivity for extreme band isolation
spl, freq = metrology.octave_filter(x, fs, filter_type='ellip', ripple=0.1)
```

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/filter_ellip_fraction_3_order_6_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/filter_ellip_fraction_3_order_6.svg" alt="Elliptic one-third-octave filter bank frequency response" width="60%"></picture>

<details>
<summary>Show the code for this figure</summary>

```python
from phonometry import metrology

# Draw this bank's response (1/3 octave, order 6, Elliptic)
metrology.OctaveFilterBank(fs=48000, fraction=3, order=6, limits=[12, 20000],
                           filter_type='ellip', ripple=0.1, show=True)
```

</details>

### 5. Bessel (`bessel`)

Bessel filters are optimized for **linear phase response** and minimal group
delay. They preserve the shape of filtered waveforms (transients) better than
any other type, but have the slowest roll-off.

```python
import numpy as np
from phonometry import metrology

# A calibrated signal in Pa so the guide runs standalone
fs = 48000
x = 0.2 * np.sin(2 * np.pi * 1000 * np.arange(fs) / fs)

# Best for pulse analysis and transient preservation
spl, freq = metrology.octave_filter(x, fs, filter_type='bessel')
```

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/filter_bessel_fraction_3_order_6_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/filter_bessel_fraction_3_order_6.svg" alt="Bessel one-third-octave filter bank frequency response" width="60%"></picture>

<details>
<summary>Show the code for this figure</summary>

```python
from phonometry import metrology

# Draw this bank's response (1/3 octave, order 6, Bessel)
metrology.OctaveFilterBank(fs=48000, fraction=3, order=6, limits=[12, 20000],
                           filter_type='bessel', show=True)
```

</details>

### 6. Linkwitz-Riley (`linkwitz_riley`)

Specifically designed for **audio crossovers**. Linkwitz-Riley filters (typically
4th order, but any even order is supported) allow splitting a signal into bands
that, when summed, result in a perfectly flat magnitude response and zero phase
difference between bands at the crossover.

```python
import numpy as np
from phonometry import metrology

# A calibrated signal in Pa so the guide runs standalone
fs = 48000
x = 0.2 * np.sin(2 * np.pi * 1000 * np.arange(fs) / fs)

signal = x
# Split signal into Low and High bands at 1000 Hz
low, high = metrology.linkwitz_riley(signal, fs, freq=1000, order=4)
# Reconstruction: low + high == signal (flat response)
```

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/crossover_lr4_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/crossover_lr4.svg" alt="Linkwitz-Riley 4th-order crossover: low-pass, high-pass and their flat sum" width="60%"></picture>

<details>
<summary>Show the code for this figure</summary>

```python
import matplotlib.pyplot as plt
import numpy as np
from scipy.signal import freqz
from phonometry import metrology

# Measure both branches: split a unit impulse and take the spectra.
fs = 48000
impulse = np.zeros(fs)
impulse[0] = 1.0
low, high = metrology.linkwitz_riley(impulse, fs, freq=1000, order=4)

w, h_lp = freqz(low, worN=8192, fs=fs)
_, h_hp = freqz(high, worN=8192, fs=fs)

fig, ax = plt.subplots(figsize=(9, 5))
ax.semilogx(w, 20 * np.log10(np.abs(h_lp) + 1e-9), label="Low-pass (LR4)")
ax.semilogx(w, 20 * np.log10(np.abs(h_hp) + 1e-9), label="High-pass (LR4)")
ax.semilogx(w, 20 * np.log10(np.abs(h_lp + h_hp) + 1e-9), "--",
            label="Sum (flat)")
ax.set(xlim=(20, 20000), ylim=(-60, 5),
       xlabel="Frequency [Hz]", ylabel="Magnitude [dB]")
ax.grid(True, which="both", alpha=0.3)
ax.legend()
plt.show()
```

</details>

## 6. Parametric EQ (`ParametricEQ`)

Biquad equalizer sections per the **RBJ Audio EQ Cookbook**
(Bristow-Johnson): peaking (bell), low/high shelf, low/high-pass, band-pass
(constant 0 dB peak or constant skirt gain), notch and all-pass, each
parameterized by `fs`, `f0`, `gain_db` and one of `q`, `bw` (bandwidth in
octaves) or `slope` exactly as the cookbook defines them. Sections cascade
as a numerically robust SOS chain, and the design is closed-form exact: a
peaking section passes exactly `gain_db` at `f0` and exactly 0 dB at DC and
Nyquist, shelves land exactly on `gain_db` at their shelved end, and the
all-pass has unit magnitude everywhere (only the phase turns).

```python
import numpy as np
from phonometry import EQSection, ParametricEQ

fs = 48000
rng = np.random.default_rng(1)
x = rng.standard_normal(fs)                 # one second of noise

eq = ParametricEQ(fs, [
    EQSection("lowshelf", 100.0, gain_db=4.0),
    EQSection("peaking", 1000.0, gain_db=-6.0, bw=1.0),  # one-octave cut
    EQSection("highshelf", 8000.0, gain_db=3.0),
])
y = eq.filter(x)              # apply the cascade
res = eq.response()           # frozen result carrying the SOS cascade
axes = res.plot()             # magnitude + phase of the cascade
```

For block processing pass `stateful=True` (the same convention as
`WeightingFilter`); the one-shot helper is `parametric_eq(x, fs, sections)`.

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/parametric_eq_family_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/parametric_eq_family.svg" alt="Magnitude responses of the RBJ Audio EQ Cookbook biquad family: peaking, shelves, low/high-pass, band-pass and notch" width="80%"></picture>

<details>
<summary>Show the code for this figure</summary>

```python
import matplotlib.pyplot as plt
from phonometry import EQSection, ParametricEQ

fs = 48000
family = [
    EQSection("peaking", 1000.0, gain_db=6.0, q=1.4),
    EQSection("lowshelf", 125.0, gain_db=6.0),
    EQSection("highshelf", 4000.0, gain_db=-6.0),
    EQSection("lowpass", 10000.0),
    EQSection("highpass", 50.0),
    EQSection("bandpass", 500.0, q=2.0),
    EQSection("notch", 2000.0, q=6.0),
]
fig, ax = plt.subplots(figsize=(10, 6))
for section in family:
    res = ParametricEQ(fs, [section]).response(f_min=20.0, f_max=20000.0)
    ax.semilogx(res.frequencies, res.magnitude_db,
                label=f"{section.filter_type} @ {section.f0:g} Hz")
ax.set(xlim=(20, 20000), ylim=(-27, 9),
       xlabel="Frequency [Hz]", ylabel="Magnitude [dB]")
ax.grid(True, which="both", alpha=0.3)
ax.legend(loc="lower center", ncols=2, fontsize=9)
plt.show()
```

</details>

## 7. Verifying the IEC 61260-1 class

`verify_filter_class` checks every band of a bank against the acceptance
limits of **IEC 61260-1:2014** (Table 1, with the fractional-octave breakpoint
mapping and log-frequency interpolation from the standard) and reports the
performance class per band with its margin in dB:

```python
from phonometry import metrology

bank = metrology.OctaveFilterBank(fs=48000, fraction=3, order=6)
result = metrology.verify_filter_class(bank)
print(result["overall_class"])          # 1
print(result["bands"][0])
# {'freq': 12.589254117941678, 'class': 1, 'margin_class1_db': 0.39999999999997266, 'margin_class2_db': 0.5999999999999727}
```

The Table 1 acceptance mask itself is public too: `class_limits(fraction,
filter_class, omega)` returns the minimum/maximum relative-attenuation
limits at normalized frequencies Ω = f/fm, the same limits the verifier
and the figure below use.

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/class_mask_overlay_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/class_mask_overlay.svg" alt="Butterworth band response threading between the forbidden regions of the IEC 61260-1 class 1 acceptance mask" width="80%"></picture>

*The order-6 Butterworth response (blue) threads between the forbidden
regions: it must attenuate at least the red mask outside the band and no more
than the purple mask inside it.*

<details>
<summary>Show the code for this figure</summary>

```python
import matplotlib.pyplot as plt
import numpy as np
from scipy.signal import sosfreqz
from phonometry import metrology

fs = 48000
bank = metrology.OctaveFilterBank(fs, fraction=1, order=6, limits=[800, 1200])
idx = int(np.argmin(np.abs(np.array(bank.freq) - 1000)))
fm, fsd = bank.freq[idx], fs / bank.factor[idx]
w, h = sosfreqz(bank.sos[idx], worN=2**15, fs=fsd)
att = -20 * np.log10(np.abs(h) + 1e-12)
delta_a = att - np.interp(fm, w, att)     # relative attenuation

grid = np.logspace(np.log10(0.05), np.log10(8), 2000)
lo1, hi1 = metrology.class_limits(1.0, 1, grid)     # class 1 min/max attenuation

fig, ax = plt.subplots(figsize=(9, 5.5))
ax.fill_between(grid, -10, lo1, alpha=0.15, color="tab:red",
                label="Forbidden: too little attenuation")
finite = np.isfinite(hi1)
ax.fill_between(grid[finite], hi1[finite], 90, alpha=0.15, color="tab:purple",
                label="Forbidden: too much attenuation")
ax.plot(w / fm, delta_a, label="Butterworth order 6")
ax.set(xscale="log", xlim=(0.08, 8), ylim=(-6, 90),
       xlabel="Normalized frequency f / fm",
       ylabel="Relative attenuation [dB]")
ax.legend()
plt.show()
```

</details>

With default parameters (order 6), **Butterworth meets class 1**, and so does
**Chebyshev II**: its `attenuation` default is now `72` dB, clearing the 70 dB
far-stopband class 1 limit (scipy pins the cheby2 equiripple floor at exactly
`attenuation`, so any value ≥ 70 dB qualifies; the 72 dB default keeps the same
+0.400 dB passband margin as Butterworth). Chebyshev I, Elliptic and Bessel do
not meet class limits at order 6: passband ripple (cheby1/ellip) and slow
roll-off (bessel) violate the mask.

### Class 0 (IEC 61260:1995 / ANSI S1.11-2004)

The tightest performance class, **class 0**, was defined by the earlier
**IEC 61260:1995** and its US twin **ANSI S1.11-2004** (both withdrawn/superseded
but still referenced for laboratory-grade instruments); IEC 61260-1:2014 dropped
it. Its class 1/2 masks differ slightly from the 2014 edition, so it lives behind
an `edition` switch rather than being mixed into the 2014 mask:

```python
from phonometry import metrology

fs = 48000
bank = metrology.OctaveFilterBank(fs, fraction=1, order=6, limits=[800, 1200])

result = metrology.verify_filter_class(bank, edition="1995")   # classes 0, 1, 2
print(result["overall_class"])          # 0  (the default Butterworth clears it)
print(result["bands"][0]["margin_class0_db"])
```

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/filter_class0_mask_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/filter_class0_mask.svg" alt="Nested pass-band acceptance corridors for class 0, 1 and 2 of IEC 61260:1995 with the order-6 Butterworth response sitting inside the tightest class 0 corridor" width="80%"></picture>

*The class 0 corridor (±0.15 dB at mid-band) is the tightest; class 1 (±0.3 dB)
and class 2 (±0.5 dB) are progressively wider. The order-6 Butterworth threads
inside class 0 across the whole pass-band.*

<details>
<summary>Show the code for this figure</summary>

```python
import matplotlib.pyplot as plt
import numpy as np
from scipy.signal import sosfreqz
from phonometry import metrology

fs = 48000
bank = metrology.OctaveFilterBank(fs, fraction=1, order=6, limits=[800, 1200])
idx = int(np.argmin(np.abs(np.array(bank.freq) - 1000)))
fm, fsd = bank.freq[idx], fs / bank.factor[idx]
w, h = sosfreqz(bank.sos[idx], worN=2**15, fs=fsd)
att = -20 * np.log10(np.abs(h) + 1e-12)
delta_a = att - np.interp(fm, w, att)

# Pass-band only: outside the band edges the maximum limit is +inf.
g = 10 ** (3 / 10)
grid = np.linspace(g ** -0.5, g ** 0.5, 1500)
pb = (w / fm >= g ** -0.5) & (w / fm <= g ** 0.5)

fig, ax = plt.subplots(figsize=(9, 5.5))
for cls in (2, 1, 0):                      # nested corridors, class 0 tightest
    lo, hi = metrology.class_limits(1.0, cls, grid, edition="1995")
    ax.plot(grid, hi, label=f"Class {cls} corridor")
    ax.plot(grid, lo, color=ax.lines[-1].get_color())
ax.plot(w[pb] / fm, delta_a[pb], "k", lw=2, label="Butterworth order 6")
ax.set(xscale="log", xlim=(g ** -0.5, g ** 0.5), ylim=(-0.7, 6),
       xlabel="Normalized frequency f / fm",
       ylabel="Relative attenuation [dB]")
ax.legend()
plt.show()
```

</details>

### What a class means physically

The masks are worst-case error bounds on a *measurement*, not abstract
grades:

- **In the passband** the corridor bounds how much the band can mis-read
  in-band content: a class 1 bank reads a mid-band tone within ±0.4 dB of
  its true level and a class 2 bank within ±0.6 dB (2014 Table 1; the
  stricter 1995 masks allowed ±0.3 dB for class 1 and ±0.15 dB for
  class 0). Toward the band edges the corridor widens, which is the honest
  admission that a tone sitting exactly on an edge is genuinely ambiguous
  between two bands (both read it about 3 dB down).
- **In the stopband** the minimum-attenuation mask bounds leakage from the
  rest of the spectrum: far from the band, class 1 demands at least 70 dB of
  relative attenuation (the reason the `cheby2` default is 72 dB). In energy
  terms, an out-of-band tone must be roughly 70 dB stronger than the band's
  own content before it doubles the band's energy reading (+3 dB). The
  practical consequence: measuring bands far below a dominant tone, the
  reading floors out at the leakage skirt about 70 dB down, and a steeper
  architecture (or higher order) is the only way to push that floor lower.
- **For the uncertainty budget**, the class is the filter's contribution to the
  measurement uncertainty: a class 1 bank adds up to a few tenths of a dB to
  a band level, comparable to a class 1 sound level meter's other tolerance
  terms, which is why instrument-grade chains specify the class of every
  stage rather than a single overall figure.

**Which architecture reaches which class?** The library's **default, Butterworth
order 6, meets class 0** in the configurations the conformance suite verifies
(octave and third-octave banks at 48 kHz), so no special setup is needed for
laboratory-grade banks in that range. The table below reports the best class
each architecture reaches under that same order-6 / 48 kHz setup; the other
architectures fall short of class 0 because they trade the IEC mask for a
different property *by construction*:

| Architecture | Best class (order 6, fs 48 kHz) | Why |
| :--- | :---: | :--- |
| `butter` (default) | **0** | Maximally-flat pass-band, monotone roll-off; fits the mask |
| `cheby2` | 1 | Flat pass-band but the mask relationship binds at class 1 |
| `cheby1` | — | Pass-band ripple violates the flatness limit |
| `ellip` | — | Pass- and stop-band ripple |
| `bessel` | — | Flat group delay bought with a slow roll-off |

So the sensible default is the common one (Butterworth order 6): it clears
class 0 in the verified configurations, while the alternative architectures are
deliberate opt-ins whose purpose (steeper roll-off, linear phase) works against
the class mask. Away from these settings (very high `fraction` or near-Nyquist
bands), always re-run `verify_filter_class` to confirm the class you need, and
raise the order if a band needs more margin.

### IEC 61260-1 filter compliance report (`.report()`)

`filter_class_compliance(bank)` wraps the same verification as a result object
that exposes `.plot()` and `.report()`, so a type-test verdict can be rendered
as a one-page accredited fiche: a per-band classification table, the
worst-margin band's measured relative attenuation overlaid on the class
corridor, and the boxed overall class-compliance result. Pass a `required_class`
on the `ReportMetadata` to add a PASS/FAIL verdict row (a bank "meets class N"
when its achieved class is at least as strict, i.e. a class index of N or
lower). The fiche renders in English by default; pass `language="es"` for a
Spanish fiche (translated fixed strings and a comma decimal separator), e.g.
`result.report("iec61260_es.pdf", language="es")`.

```python
from phonometry import OctaveFilterBank, ReportMetadata, filter_class_compliance

bank = OctaveFilterBank(fs=48000, fraction=1, order=6, limits=[125, 4000])
result = filter_class_compliance(bank)   # overall_class == 1

result.report(
    "iec61260.pdf",
    metadata=ReportMetadata(
        specimen="1/1-octave filter bank",
        measurement_standard="IEC 61260-1:2014",
        required_class=1,                # class 1 (or stricter) required
    ),
)                                        # -> Class 1 - COMPLIES, PASS
```

Passing `edition="1995"` verifies against the older IEC 61260:1995 /
ANSI S1.11-2004 mask, which keeps the stricter **class 0** that the 2014 edition
dropped; a higher-order bank can then be certified to class 0:

```python
bank = OctaveFilterBank(fs=48000, fraction=1, order=6, limits=[250, 4000])
result = filter_class_compliance(bank, edition="1995")   # overall_class == 0
result.report(
    "iec61260_1995.pdf",
    metadata=ReportMetadata(
        measurement_standard="IEC 61260:1995",
        required_class=0,                # class 0 (1995 edition) required
    ),
)                                        # -> Class 0 - COMPLIES, PASS
```

Both rendered example fiches live under `.github/reports/` (regenerated with
`make reports`): the 2014-edition class-1
[`iec61260_filter_example.pdf`](https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/reports/iec61260_filter_example.pdf)
and the 1995-edition class-0
[`iec61260_filter_1995_example.pdf`](https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/reports/iec61260_filter_1995_example.pdf).

## 8. Signal Decomposition and Stability

By setting `sigbands=True`, you can retrieve the time-domain components of each
band. This allows for advanced analysis or comparing how different architectures
(e.g., Butterworth vs Chebyshev) affect the signal phase and transient response.

```python
import numpy as np
from phonometry import metrology

# 1. Generate a signal (Sum of 250Hz and 1000Hz)
fs = 48000
t = np.linspace(0, 0.5, int(fs * 0.5), endpoint=False)
y = np.sin(2 * np.pi * 250 * t) + np.sin(2 * np.pi * 1000 * t)

# 2. Compare architectures (Butterworth vs Chebyshev II)
spl_b, freq, xb_butter = metrology.octave_filter(y, fs=fs, fraction=1, sigbands=True, filter_type='butter')
spl_c2, _, xb_cheby2 = metrology.octave_filter(y, fs=fs, fraction=1, sigbands=True, filter_type='cheby2')

# 'xb_butter' and 'xb_cheby2' contain the time-domain signals per band
```

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/signal_decomposition_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/signal_decomposition.svg" alt="Time-domain band decomposition comparing Butterworth and Chebyshev II, including the impulse response" width="80%"></picture>

*The plot compares the **Butterworth** (solid blue) and **Chebyshev II** (dashed
red) responses. The bottom plot shows the **Impulse Response**, highlighting the
differences in stability and transient decay.*

<details>
<summary>Show the code for this figure</summary>

```python
import matplotlib.pyplot as plt
import numpy as np
from phonometry import metrology

fs = 48000
t = np.linspace(0, 0.5, int(fs * 0.5), endpoint=False)
y = np.sin(2 * np.pi * 250 * t) + np.sin(2 * np.pi * 1000 * t)

bank_b = metrology.OctaveFilterBank(fs=fs, fraction=1, order=6, limits=[100.0, 2000.0])
bank_c = metrology.OctaveFilterBank(fs=fs, fraction=1, order=6, limits=[100.0, 2000.0],
                          filter_type="cheby2")
_, freq, xb_butter = bank_b.filter(y, sigbands=True)
_, _, xb_cheby2 = bank_c.filter(y, sigbands=True)

fig, axes = plt.subplots(len(freq), 1, figsize=(9, 2 * len(freq)), sharex=True)
for ax, fc, xb, xc in zip(axes, freq, xb_butter, xb_cheby2):
    ax.plot(t, xb, label="Butterworth")
    ax.plot(t, xc, "--", label="Chebyshev II")
    ax.set_title(f"{fc:.0f} Hz band")
    ax.set_xlim(0, 0.04)
axes[0].legend()
axes[-1].set_xlabel("Time [s]")
plt.tight_layout()
plt.show()
```

</details>

> [!NOTE]
> **Why do the signals look shifted in time?**
> Digital IIR filters (like Butterworth or Chebyshev) have **non-linear phase
> responses**, which results in frequency-dependent **Group Delay**. In the 250 Hz
> band, you can see that the Chebyshev II filter has a different propagation delay
> compared to the Butterworth filter. This is a normal physical property of these
> architectures: more aggressive frequency roll-offs usually come at the cost of
> higher group delay and phase distortion.

### Group delay, quantified

The group delay $\tau_g(\omega) = -\frac{d\phi(\omega)}{d\omega}$ of the
1 kHz octave band shows the trade-off directly: Bessel stays nearly flat across
the passband (transient shapes survive), while Chebyshev I and Elliptic pay for
their steep roll-off with strong delay peaks at the band edges.

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/group_delay_comparison_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/group_delay_comparison.svg" alt="Group delay of the 1 kHz octave band for the five architectures: Bessel nearly flat, Chebyshev and Elliptic peaking at the band edges" width="80%"></picture>

<details>
<summary>Show the code for this figure</summary>

```python
import matplotlib.pyplot as plt
import numpy as np
from scipy.signal import group_delay
from phonometry import metrology

fs = 48000
w = np.logspace(np.log10(500), np.log10(2000), 1024)
fig, ax = plt.subplots(figsize=(9, 5))
for ftype in ("butter", "cheby1", "cheby2", "ellip", "bessel"):
    bank = metrology.OctaveFilterBank(fs, fraction=1, order=6, limits=[800, 1200],
                            filter_type=ftype)
    idx = int(np.argmin(np.abs(np.array(bank.freq) - 1000)))
    fsd = fs / bank.factor[idx]
    # Group delay of an SOS cascade = sum of the sections' group delays
    gd = sum(group_delay((sec[:3], sec[3:]), w=w, fs=fsd)[1]
             for sec in bank.sos[idx])
    ax.semilogx(w, gd / fsd * 1000, label=ftype)
ax.set(xlim=(500, 2000), xlabel="Frequency [Hz]", ylabel="Group delay [ms]")
ax.grid(True, which="both", alpha=0.3)
ax.legend()
plt.show()
```

</details>

## 9. Zero-phase filtering

For offline analysis you can eliminate group delay entirely: `zero_phase=True`
filters each band forward-backward (`scipy.signal.sosfiltfilt`), keeping band
signals time-aligned with the input. The effective attenuation doubles and the
effective passband narrows, lowering the measured broadband band level by
~0.2 to 0.3 dB per band (a pure in-band tone is unaffected); prefer forward
filtering when the absolute band SPL must match single-pass conventions, and
reserve zero-phase for when the temporal envelope matters (e.g. reverberation
decay). The option is incompatible with stateful (block) processing.

```python
import numpy as np
from phonometry import metrology

fs = 48000
t = np.linspace(0, 0.5, int(fs * 0.5), endpoint=False)
y = np.sin(2 * np.pi * 250 * t) + np.sin(2 * np.pi * 1000 * t)

bank = metrology.OctaveFilterBank(fs=48000, fraction=3)
spl, freq, xb = bank.filter(y, sigbands=True, zero_phase=True)
```

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/zero_phase_comparison_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/zero_phase_comparison.svg" alt="Causal versus zero-phase filtering of a tone burst: the zero-phase output stays time-aligned with the input" width="80%"></picture>

*Causal filtering delays the burst by the filter's group delay; zero-phase
filtering keeps it aligned with the input.*

<details>
<summary>Show the code for this figure</summary>

```python
import matplotlib.pyplot as plt
import numpy as np
from phonometry import metrology

fs = 48000
t = np.linspace(0, 0.15, int(fs * 0.15), endpoint=False)
x = np.zeros_like(t)                      # 250 Hz tone burst mid-frame
start, end = int(0.05 * fs), int(0.10 * fs)
x[start:end] = np.sin(2 * np.pi * 250 * t[start:end]) * np.hanning(end - start)

bank = metrology.OctaveFilterBank(fs=fs, fraction=1, order=6, limits=[200.0, 300.0])
_, _, fwd = bank.filter(x, sigbands=True, calculate_level=False)
_, _, zp = bank.filter(x, sigbands=True, calculate_level=False,
                       zero_phase=True)

fig, ax = plt.subplots(figsize=(9, 4.5))
ax.plot(t, x, color="gray", alpha=0.5, label="Input burst (250 Hz)")
ax.plot(t, fwd[0], label="Causal (group delay)")
ax.plot(t, zp[0], "--", label="zero_phase=True (aligned)")
ax.set(xlabel="Time [s]", ylabel="Amplitude")
ax.legend()
plt.show()
```

</details>

## References

- International Electrotechnical Commission. (2014). *Electroacoustics —
  Octave-band and fractional-octave-band filters — Part 1: Specifications*
  (IEC 61260-1:2014).
  [IEC webstore](https://webstore.iec.ch/en/publication/5063).
  The band-edge mathematics of section 1 and the class acceptance masks
  verified in section 6.
- Oppenheim, A. V., & Schafer, R. W. (2010). *Discrete-time signal processing*
  (3rd ed.). Pearson. ISBN 978-0-13-198842-2.
  [Open Library record](https://openlibrary.org/isbn/9780131988422).
  The pole-zero, stability and multirate theory condensed in section 1: SOS
  cascades, the bilinear transform and decimation.
- Bristow-Johnson, R. *Audio EQ Cookbook*. Republished as a W3C Working
  Group Note (ed. R. Toy), 8 June 2021.
  [w3.org/TR/audio-eq-cookbook](https://www.w3.org/TR/audio-eq-cookbook/).
  The biquad coefficient recipes and the Q / bandwidth / shelf-slope
  parameterization behind `ParametricEQ` (section 6).
- Smith, J. O. *Introduction to digital filters with audio applications*
  (online book). Center for Computer Research in Music and Acoustics (CCRMA),
  Stanford University.
  [ccrma.stanford.edu/~jos/filters](https://ccrma.stanford.edu/~jos/filters/).
  A free companion treatment of digital-filter design and analysis, from
  pole-zero geometry to filter stability.

## Standards

IEC 61260-1:2014, *Electroacoustics — Octave-band and
fractional-octave-band filters — Part 1: Specifications* — the base-10 mid
frequencies and band edges of §1 (5.2-5.5), the nominal band labels, and the
Table 1 class 1 / class 2 acceptance limits (with the fractional-octave
breakpoint mapping and log-frequency interpolation) verified in §6.
IEC 61260:1995 and ANSI S1.11-2004, *Octave-Band and Fractional-Octave-Band …
Filters*: the withdrawn edition's Table 1 (identical between the two) supplies
the stricter class 0 mask offered by ``edition="1995"``, and the band-edge
convention on which every bank places its −3 dB
points. ISO 266: the preferred-frequency series behind the nominal band
labels reported by `nominal_frequencies`.

---


<!-- source: docs/weighting.md | canonical: https://jmrplens.github.io/phonometry/guides/weighting/ -->

# Frequency Weighting (A, B, C, D, G, AU, Z)

Frequency weighting curves simulate the human ear's sensitivity. A, C and Z
are specified by **IEC 61672-1:2013**; the infrasound G curve is specified by
**ISO 7196:1995**. Three more curves round out the family: the historical
**B** (ANSI S1.4-1983), the withdrawn aircraft-noise **D** (IEC 537) and
**AU** (IEC 61012) for audible sound in the presence of ultrasound
(section 4).

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/weighting_responses_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/weighting_responses.svg" alt="A, B, C, D, AU and Z frequency weighting curves with a zoom showing the positive region of the A curve (+1.27 dB at 2.5 kHz)" width="80%"></picture>

<details>
<summary>Show the code for this figure</summary>

```python
import matplotlib.pyplot as plt
import numpy as np
from phonometry import metrology

# 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", "B", "C", "D", "AU", "Z"):
    spectrum = np.fft.rfft(metrology.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, 20000), ylim=(-80, 15),
       xlabel="Frequency [Hz]", ylabel="Response [dB]")
ax.grid(True, which="both", alpha=0.3)
ax.legend()
plt.show()
```

</details>

* **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`):** Zero weighting, completely flat response.
* **G-Weighting (`G`):** Infrasound weighting per ISO 7196 (see below).
* **B-Weighting (`B`):** Historical middle curve of ANSI S1.4-1983 (section 4).
* **D-Weighting (`D`):** Aircraft-noise weighting of the withdrawn IEC 537 (section 4).
* **AU-Weighting (`AU`):** A-weighting with the IEC 61012 ultrasound cutoff (section 4).

## 1. Where the curves come from

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:

$$
f_1 = 20.599\ \text{Hz}, \quad f_2 = 107.653\ \text{Hz}, \quad
f_3 = 737.862\ \text{Hz}, \quad f_4 = 12194.217\ \text{Hz}
$$

C is a band-pass with double poles at $f_1$ and $f_4$ (2 zeros at the origin);
A adds the $f_2$ and $f_3$ poles (4 zeros), which is why it keeps falling
through the low-mids. Both are normalized to exactly 0 dB at 1 kHz. Z is the
absence of weighting. The full pole/zero derivation is in the
[Theory](https://jmrplens.github.io/phonometry/reference/theory/signal-analysis/) page.

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/diagram_equal_loudness_weighting_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/diagram_equal_loudness_weighting.svg" alt="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 filter" width="92%"></picture>

### A short history: A, B, C and Z

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 section 4.

### When C − A matters

Because A discards bass and C keeps it, the difference
$L_{Ceq} - L_{Aeq}$ 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).

```python
import numpy as np
from phonometry import metrology

# 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 = metrology.leq(metrology.weighting_filter(x, fs, curve="A"))
lc = metrology.leq(metrology.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.
```

## 2. Basic usage

```python
import numpy as np
from phonometry import metrology

# 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 = metrology.weighting_filter(recording, fs, curve='A')

# Apply C-weighting for peak analysis
c_weighted_signal = metrology.weighting_filter(recording, fs, curve='C')
```

## 3. Infrasound: G-weighting (ISO 7196)

The **G frequency weighting** (ISO 7196:1995) rates infrasound the way A-weighting
rates audible noise. It is defined by a pole-zero configuration with 0 dB gain at
10 Hz, rises at 12 dB/octave from 1 Hz to 20 Hz (matching the steep growth of
perception in that band) and falls off at 24 dB/octave outside it. Use it for
sources with significant energy below 20 Hz (wind turbines, HVAC, blasting):

```python
import numpy as np
from phonometry import metrology

# 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)

g_weighted = metrology.weighting_filter(recording, fs, curve='G')
```

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/g_weighting_response_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/g_weighting_response.svg" alt="G-weighting frequency response from 0.1 Hz to 1 kHz with the ISO 7196 Table 2 nominal values overlaid" width="80%"></picture>

<details>
<summary>Show the code for this figure</summary>

```python
import matplotlib.pyplot as plt
import numpy as np
from phonometry import metrology

# Measure the G response: weight a centered unit impulse and take its
# spectrum. A long buffer gives the resolution the infrasound range
# needs (20 s -> 0.05 Hz).
fs = 4000
impulse = np.zeros(20 * fs)
impulse[impulse.size // 2] = 1.0
freqs = np.fft.rfftfreq(impulse.size, 1 / fs)
spectrum = np.fft.rfft(metrology.weighting_filter(impulse, fs, curve="G"))

fig, ax = plt.subplots(figsize=(9, 5))
ax.semilogx(freqs[1:],
            20 * np.log10(np.abs(spectrum[1:]) + np.finfo(float).eps))
ax.plot(10, 0, "o", color="tab:red", label="0 dB at 10 Hz")
ax.set(xlim=(0.1, 1000), ylim=(-90, 15),
       xlabel="Frequency [Hz]", ylabel="G-weighting response [dB]")
ax.grid(True, which="both", alpha=0.3)
ax.legend()
plt.show()
```

</details>

The implementation follows the ISO 7196 Table 1 pole/zero values exactly and is
verified in CI against every Table 2 nominal response value (0.25 Hz to 315 Hz).
`WeightingFilter(fs, "G")` supports the same multichannel and stateful block
processing as A/C. Levels measured with the G curve are reported as
L<sub>pG</sub> (or L<sub>Geq</sub> for the equivalent level over time).

## 4. Historical and special-purpose curves: B, D and AU

Three more curves complete the family. All three share the machinery of the
IEC 61672-1 curves (0 dB at 1 kHz, `high_accuracy` oversampling, multichannel
and stateful block processing).

### B (ANSI S1.4-1983, historical)

The middle curve of the original A/B/C level-switching scheme, drawn from
the ~70-phon equal-loudness contour. Analytically it is the C weighting with
one more zero at the origin and one extra real pole at
$f_5 = 158.49\ \text{Hz}$ (Appendix C of ANSI S1.4-1983), so it discards
less bass than A and more than C. It was dropped when IEC 61672-1 replaced
the older sound-level-meter standards; use it only to reproduce historical
data and measurements taken under older national codes (some legacy
automotive test procedures reported dB(B)). The implementation follows the
ANSI S1.4-1983 Appendix C constants and is pinned in CI against the Table IV
response values, within the strictest Table V mask (Type 0).

### D (IEC 537, withdrawn: aircraft noise)

The D weighting approximated the *perceived noisiness* contours used by the
perceived-noise-level (PNL) rating, so a plain sound level meter could
estimate aircraft noise: the +11.5 dB hump around 3.15 kHz is where jet
turbomachinery whine annoys most (it is deliberately *not* an equal-loudness
feature). NASA's aircraft-noise handbook gives the classic rule of thumb
$L_{PN} \approx L_D + 7\ \text{dB}$. IEC 537 was withdrawn and current
certification practice reports EPNL from one-third-octave analysis or plain
A-weighted levels, so `D` is provided for historical data and comparisons.
With the standard unavailable, the implementation uses the widely published
IEC 537 rational transfer function and is cross-checked against two
independent implementations (SQAT's zeros/poles and librosa's closed form,
which agree within 0.002 dB) and pinned in CI against the IEC 537 table
republished in NASA CR-3406.

```python
import numpy as np
from phonometry import metrology

# A 3.15 kHz whine sits right on the D-weighting hump: D rates it
# 10 dB *louder* than A does.
fs = 96000
t = np.arange(fs) / fs
whine = 0.1 * np.sin(2 * np.pi * 3150 * t)

ld = metrology.leq(metrology.weighting_filter(whine, fs, curve="D"))
la = metrology.leq(metrology.weighting_filter(whine, fs, curve="A"))
print(f"LD = {ld:.1f} dB   LA = {la:.1f} dB")
# LD = 82.5 dB   LA = 72.2 dB
```

### AU (IEC 61012, current: audible sound in the presence of ultrasound)

The only one of the three still in force. `AU` is the A weighting cascaded
with the **U** low-pass filter of IEC 61012:1990 (six poles, Table 2): flat
relative to A up to 10 kHz, then a steep cutoff (-13 dB at 16 kHz, -61.8 dB
at 40 kHz for U alone). Use it when strong ultrasonic components (ultrasonic
cleaners and welders, rodent repellers, some public-space deterrents) would
otherwise leak into an A-weighted reading through the meter's imperfect
high-frequency roll-off and overstate the *audible* exposure:

```python
import numpy as np
from phonometry import metrology

# 1 kHz tone (audible) buried under a strong 25 kHz ultrasonic component.
fs = 96000
t = np.arange(fs) / fs
audible = 0.1 * np.sin(2 * np.pi * 1000 * t)
x = audible + 1.0 * np.sin(2 * np.pi * 25000 * t)

la = metrology.leq(metrology.weighting_filter(x, fs, curve="A"))
lau = metrology.leq(metrology.weighting_filter(x, fs, curve="AU"))
la_ref = metrology.leq(metrology.weighting_filter(audible, fs, curve="A"))
print(f"LA = {la:.1f} dB   LAU = {lau:.1f} dB   audible alone = {la_ref:.1f} dB")
# LA = 78.6 dB   LAU = 71.0 dB   audible alone = 71.0 dB
# The ultrasound inflates LA by 7.6 dB; AU recovers the audible level.
```

Ultrasound only reaches a digital filter when the sample rate captures it,
so measure at 96 kHz or more (at 48 kHz there is nothing above 24 kHz to
reject); the AU design internally oversamples toward 288 kHz to keep the
steep U roll-off accurate. Levels are reported as L<sub>AU</sub>. The
implementation follows the Table 2 pole locations exactly (they reproduce
every Table 1 nominal value within 0.05 dB) and is verified in CI against
the Table 1 tolerances up to 40 kHz.

## 5. `weighting_filter()` / `WeightingFilter` parameters

| Parameter | Type | Units | Range / default | Notes |
| :--- | :--- | :--- | :--- | :--- |
| `x` | 1D or 2D array | any | non-empty | 2D is `[channels, samples]` |
| `fs` | int | Hz | > 0 | |
| `curve` | str | — | `'A'` (default), `'B'`, `'C'`, `'D'`, `'G'`, `'AU'`, `'Z'` | `'G'` per ISO 7196 (infrasound); `'B'`/`'D'` historical (§4); `'AU'` per IEC 61012 (§4); `'Z'` is a bypass |
| `high_accuracy` | bool | — | default `True` (function); class default `None` resolves to `not stateful` | Internal oversampling keeps A/C in class 1 up to 16 kHz; details in §7 |
| `stateful` | bool (class only) | — | default `False` | Carries filter state across blocks (streaming) |
| `steady_ic` | bool (class only) | — | default `False` | Steady-state initial conditions (no onset transient) |

## 6. Reusable filter object

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

```python
import numpy as np
from phonometry import metrology

# 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 = metrology.WeightingFilter(fs, "A")
signals = [recording]                # your batch of recordings
for recording in signals:
    weighted = wf.filter(recording)
```

## 7. High-frequency accuracy (`high_accuracy`)

A plain bilinear-transform design compresses the response near Nyquist: at
fs = 48 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 fs = 48 kHz).

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/weighting_accuracy_hf_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/weighting_accuracy_hf.svg" alt="A-weighting high-frequency accuracy at 48 kHz: analytic curve versus plain bilinear versus oversampled design, with error subplot" width="80%"></picture>

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

<details>
<summary>Show the code for this figure</summary>

```python
import matplotlib.pyplot as plt
import numpy as np
from phonometry import metrology

# 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 = metrology.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()
```

</details>

- `high_accuracy=False` restores the legacy plain-bilinear behavior.
- For `'G'` the flag is silently ignored: its 0.25–315 Hz range is already
  exact with the plain design.
- **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`.

```python
import numpy as np
from phonometry import metrology

# 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 = metrology.weighting_filter(recording, fs, curve="A", high_accuracy=False)

# Stateful block processing (legacy design, state carried between blocks)
wf = metrology.WeightingFilter(fs, "A", stateful=True)
blocks = [recording]                 # your sequence of recording blocks
for block in blocks:
    weighted = wf.filter(block)
```

See [Block Processing](https://jmrplens.github.io/phonometry/guides/block-processing/) for the streaming workflow and
[Theory](https://jmrplens.github.io/phonometry/reference/theory/signal-analysis/) for the analytic curve definitions.

## 8. Verifying against the tolerance tables (IEC 61672-1, ANSI S1.4, IEC 61012)

`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 $f = 1000 \cdot 10^{n/10}$,
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):

```python
from phonometry import metrology

result = metrology.verify_weighting_class(metrology.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.

The same verifier covers the section 4 curves that have published tolerance
tables. For `B` it uses ANSI S1.4-1983 (Table IV design goals, Table V
limits) and the "class" verdicts read as the standard's instrument **Types**
1 and 2. For `AU` it uses IEC 61012:1990 Table 1 (nominal A + nominal U with
the separate-unit tolerances, zero at the 1 kHz reference); IEC 61012
publishes a single tolerance set, so both margin slots agree and the verdict
is simply complies (1) or not (`None`) — note that checking the rows above
20 kHz needs `fs` ≥ 96 kHz (below that they are dropped and the verdict is
`range_limited`). `G` and `D` are rejected: ISO 7196 defines one ±1 dB
tolerance with no class structure, and the withdrawn IEC 537 left no
tolerance table behind (both curves are pinned numerically in the CI
conformance report instead).

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/weighting_class_mask_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/weighting_class_mask.svg" alt="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 dotted" width="80%"></picture>

*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.*

<details>
<summary>Show the code for this figure</summary>

```python
import matplotlib.pyplot as plt
import numpy as np
from phonometry import metrology

freqs, lower1, upper1 = metrology.weighting_class_limits(1)
_, lower2, upper2 = metrology.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 = metrology.verify_weighting_class(metrology.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()
```

</details>

## References

- Fletcher, H., & Munson, W. A. (1933). Loudness, its definition, measurement
  and calculation. *The Journal of the Acoustical Society of America*, 5(2),
  82-108. [doi:10.1121/1.1915637](https://doi.org/10.1121/1.1915637).
  The original equal-loudness measurements whose 40-phon contour the A-curve
  inverts (section 1).
- International Organization for Standardization. (2023). *Acoustics —
  Normal equal-loudness-level contours* (ISO 226:2023).
  [iso.org catalogue](https://www.iso.org/standard/83117.html).
  The modern successors of the Fletcher-Munson curves, drawn in the diagram
  of section 1.
- International Electrotechnical Commission. (2013). *Electroacoustics —
  Sound level meters — Part 1: Specifications* (IEC 61672-1:2013).
  [IEC webstore](https://webstore.iec.ch/en/publication/5708).
  The normative A/C/Z definitions, the analytic Annex E curves and the
  Table 3 acceptance limits verified in section 8.
- American National Standards Institute. (1983). *Specification for Sound
  Level Meters* (ANSI S1.4-1983). The historical B weighting: Appendix C
  analytic definition (Formula C2), Table IV design goals and Table V
  tolerance limits checked by `verify_weighting_class` in section 8.
- International Electrotechnical Commission. (1990). *Filters for the
  measurement of audible sound in the presence of ultrasound*
  (IEC 61012:1990). [IEC webstore](https://webstore.iec.ch/en/publication/4383).
  The AU weighting: U-weighting pole locations (Table 2), nominal responses
  and tolerances (Table 1) and the combined AU definition of subclause 2.2.
- International Electrotechnical Commission. (1976). *Frequency weighting
  for the measurement of aircraft noise (D-weighting)* (IEC 537:1976,
  withdrawn). Implemented from its published rational transfer function and
  cross-checked against independent implementations (section 4).
- Bennett, R. L., & Pearsons, K. S. (1981). *Handbook of Aircraft Noise
  Metrics* (NASA CR-3406). NASA.
  [ntrs.nasa.gov](https://ntrs.nasa.gov/citations/19810013341).
  Republishes the IEC 537 D-weighting table (Table SLD-I) used to pin the
  D response in CI.

## Standards

IEC 61672-1:2013, *Electroacoustics — Sound level meters —
Part 1: Specifications*: the 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`. ISO 7196:1995, *Acoustics — Frequency-weighting characteristic for
infrasound measurements*: the G-weighting pole/zero definition (Table 1),
verified against every Table 2 nominal response value (0.25 Hz to 315 Hz).
ANSI S1.4-1983, *Specification for Sound Level Meters*: the historical B
weighting (Appendix C, Tables IV and V). IEC 61012:1990, *Filters for the
measurement of audible sound in the presence of ultrasound*: the AU
weighting (Tables 1 and 2, subclause 2.2). IEC 537:1976 (withdrawn),
*Frequency weighting for the measurement of aircraft noise*: the D
weighting, from its published rational transfer function.

---


<!-- source: docs/time-weighting.md | canonical: https://jmrplens.github.io/phonometry/guides/time-weighting/ -->

# Time Weighting and Integration

Accurate SPL measurement requires capturing energy over specific time windows.
phonometry implements exact time constants per **IEC 61672-1:2013**.

## 1. The exponential detector

A sound level meter's needle cannot follow the pressure waveform: it shows a
running *mean square* with an exponential memory. Formally (IEC 61672-1, 3.8):

$$
\tau\ \frac{dy}{dt} + y = x^2(t)
\quad\Longleftrightarrow\quad
y(t) = \frac{1}{\tau} \int_{-\infty}^{t} x^2(\xi)\ e^{-(t-\xi)/\tau}\ d\xi
$$

a first-order low-pass on the squared signal. The time constant τ sets the
trade-off: **Fast** (125 ms) follows speech-like fluctuations, **Slow** (1 s)
steadies the readout for quasi-stationary noise. After a step onset the
envelope reaches 63 % of its final value in one τ and ~99.97 % after 8τ;
that is why level analyses discard the first instants of a recording.

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/diagram_time_weighting_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/diagram_time_weighting.svg" alt="The exponential-detector chain: the band pressure is squared, smoothed by a one-pole RC low-pass with time constant tau, and converted to decibels, with the Fast, Slow and Impulse time constants listed" width="88%"></picture>

## 2. The three time weightings

* **Fast (`fast`):** τ = 125 ms. Standard for noise fluctuations.
* **Slow (`slow`):** τ = 1000 ms. Standard for steady noise.
* **Impulse (`impulse`):** **Asymmetric** ballistics. 35 ms rise time for rapid
  onset capture, 1500 ms decay for readability.

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/anim_time_weighting_dark.gif"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/anim_time_weighting.gif" alt="Animation: a tone burst driving the RC exponential detector, the capacitor charging and draining, while the Fast, Slow and Impulse meter needles follow their own ballistics" width="640" height="360" loading="lazy"></picture>

[Watch the high-resolution video (WebM)](https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/anim_time_weighting.webm)

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/time_weighting_analysis_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/time_weighting_analysis.svg" alt="Fast, Slow and Impulse time weighting responses to a noise burst" width="80%"></picture>

<details>
<summary>Show the code for this figure</summary>

```python
import numpy as np
import matplotlib.pyplot as plt
from phonometry import metrology

fs = 48000
t = np.arange(int(fs * 4)) / fs
burst = np.zeros_like(t)  # 0.5 s noise burst (Pa) starting at t = 1 s
rng = np.random.default_rng(42)
burst[fs:int(1.5 * fs)] = 0.2 * rng.standard_normal(int(0.5 * fs))

p0 = 2e-5
plt.figure()
for mode in ('fast', 'slow', 'impulse'):
    envelope = metrology.time_weighting(burst, fs, mode=mode)
    plt.plot(t, 10 * np.log10(np.maximum(envelope, 1e-12) / p0**2), label=mode)
plt.xlabel('Time [s]')
plt.ylabel('Level [dB SPL]')
plt.legend()
plt.show()
```

</details>

```python
import numpy as np
from phonometry import metrology

# 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)

# Calculate energy envelope (Mean Square)
energy_envelope = metrology.time_weighting(recording, fs, mode='fast')
# dB SPL relative to 20 μPa
spl_t = 10 * np.log10(energy_envelope / (2e-5)**2)

print(f"Steady-state Fast level: {spl_t[-1]:.1f} dB SPL")
# Steady-state Fast level: 77.0 dB SPL
```

The asymmetric Impulse ballistics use two constants, a fast attack and a slow
decay, switching per sample on the sign of the change:

$$
y[n] = y[n-1] + \alpha \ (x^2[n] - y[n-1]), \qquad
\alpha = \begin{cases}1 - e^{-1/(f_s \cdot 0.035)} & x^2[n] > y[n-1]\\[2pt] 1 - e^{-1/(f_s \cdot 1.5)} & \text{otherwise}\end{cases}
$$

### Choosing F, S or I

- **Fast** is the default of nearly every modern method: percentile levels,
  impulsive-event detection, community noise, the general "level vs time"
  plot. Its 125 ms constant is of the same order as the ear's own loudness
  integration time, so an LAF trace roughly tracks what a listener notices.
- **Slow** suits quasi-stationary sources and any procedure that needs a
  steady readout: it averages away the flicker of a fluctuating source at
  the price of missing short events (a 100 ms burst peaks about 7.5 dB lower
  on S than on F). Some legacy methods prescribe it outright, most famously
  aircraft-certification levels, which are built from Slow-weighted samples.
- **Impulse** is legacy, and deprecated for rating. It was a 1960s attempt
  to make a meter needle track the perceived loudness of impacts; it does
  not (the 35 ms attack still misses very short impulses, and the 1.5 s hold
  exaggerates duration). It entered the international standards with
  IEC 60651 and was dropped from the requirements of its successor
  IEC 61672-1, whose first edition (2002) explains why: I-weighted levels
  are not suitable for rating impulsive sounds. It survives in meters only
  for continuity with older national requirements. Modern practice rates
  impulsiveness with
  $L_{Aeq}$ plus an adjustment (ISO 1996-1 Table A.1, or the onset analysis
  of [Impulsive-sound prominence](impulse-prominence.md)) and assesses
  hearing-damage risk with $L_{Cpeak}$, never with I-weighted levels.

## 3. `time_weighting()` / `TimeWeighting` parameters

| Parameter | Type | Units | Range / default | Notes |
| :--- | :--- | :--- | :--- | :--- |
| `x` | 1D or 2D array | pressure (any scale) | non-empty | Squared internally; output is a mean-square envelope |
| `fs` | int | Hz | > 0 | |
| `mode` | str | — | `'fast'` (default), `'slow'`, `'impulse'` | τ = 125 ms / 1 s / 35 ms attack + 1.5 s decay |
| `TimeWeighting(fs, mode)` (class) | — | — | — | Stateful variant for streaming: `process(x)` carries the integrator state between blocks |

The output has the units of $x^2$: take `10*log10(y / p0**2)` for SPL or use
the level functions, which do it for you.

## 4. Verified ballistics (IEC 61672-1 Table 4)

The Fast envelope's response to 4 kHz tonebursts lands exactly on the
standard's reference values: the example below verifies the 200 ms Fast
burst row; the CI suite covers the full Table 4, from 1 s down to 1 ms for F
and 1 s down to 2 ms for S, at class 1 acceptance limits:

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/tone_burst_iec_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/tone_burst_iec.svg" alt="Fast envelope responses to 200, 50 and 10 ms tone bursts peaking exactly at the IEC 61672-1 Table 4 reference values" width="80%"></picture>

<details>
<summary>Show the code for this figure</summary>

```python
import numpy as np
import matplotlib.pyplot as plt
from phonometry import metrology

fs = 48000
t = np.arange(int(fs * 2)) / fs
tone = np.sin(2 * np.pi * 4000 * t)

# Steady-state Fast reference of the continuous tone
reference = metrology.time_weighting(tone, fs, mode='fast')[int(1.5 * fs):].mean()

# 200 ms burst of the same tone (IEC 61672-1 Table 4 target: -1.0 dB)
burst = np.zeros_like(t)
burst[int(0.5 * fs):int(0.7 * fs)] = tone[int(0.5 * fs):int(0.7 * fs)]
envelope = metrology.time_weighting(burst, fs, mode='fast')
env_db = 10 * np.log10(np.maximum(envelope / reference, 1e-6))

plt.figure()
plt.plot(t, env_db, label='Fast envelope')
plt.axhline(-1.0, linestyle='--', label='IEC target −1.0 dB')
plt.xlabel('Time [s]')
plt.ylabel('Level re steady state [dB]')
plt.legend()
plt.show()
```

</details>

## 5. Initial state

By default, the exponential integrator starts from rest (`y[-1] = 0`). Passing
`initial_state=None` leaves this default unspecified, while `initial_state='zero'`
requests the same zero state explicitly. If the recorded segment begins after a
steady signal is already present, you can start from the first sample energy instead:

```python
import numpy as np
from phonometry import metrology

# 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)

energy_envelope = metrology.time_weighting(recording, fs, mode='fast', initial_state='first')
```

## 6. Block processing

For block processing, pass the last output value from the previous block as the
next block's `initial_state` instead of resetting each block:

```python
from phonometry import metrology

state = None

# audio_blocks: consecutive frames of your calibrated recording (Pa),
#   streamed from your sound card or read from a WAV in blocks.
for block in audio_blocks:
    energy_envelope = metrology.time_weighting(block, fs, mode='fast', initial_state=state)
    state = energy_envelope[-1]
```

For multichannel blocks with time on the last axis, carry one state per channel:
use `state = energy_envelope[..., -1]`. A scalar `initial_state` is applied to
every channel, while an array must match or broadcast to the non-time shape,
such as `(n_channels,)` for input shaped `(n_channels, n_samples)`.

Or let the `TimeWeighting` class carry the state for you:

```python
from phonometry import metrology

tw = metrology.TimeWeighting(fs, mode='fast')
# audio_blocks: consecutive frames of your calibrated recording (Pa),
#   streamed from your sound card or read from a WAV in blocks.
for block in audio_blocks:
    energy_envelope = tw.process(block)
```

Concatenated block outputs are exactly equal to a single continuous call
(verified for all three modes, mono and multichannel). Call `tw.reset()` to
start from rest again.

## 7. Performance note

The `impulse` mode uses an asymmetric kernel that is JIT-compiled when
[numba](https://numba.pydata.org/) is installed (`pip install phonometry[perf]`).
Without numba a pure-Python fallback produces identical results, just slower.

See [Integrated & Statistical Levels](https://jmrplens.github.io/phonometry/guides/levels/) for Leq/LN metrics built on
these envelopes, and [Why phonometry](https://jmrplens.github.io/phonometry/reference/why-phonometry/) for the IEC
61672-1 tone-burst verification.

## References

- International Electrotechnical Commission. (2013). *Electroacoustics —
  Sound level meters — Part 1: Specifications* (IEC 61672-1:2013).
  [IEC webstore](https://webstore.iec.ch/en/publication/5708).
  The exponential-detector definition, the F and S time constants and the
  Table 4 toneburst responses the ballistics are verified against in CI.

## Standards

IEC 61672-1:2013, *Electroacoustics — Sound level meters —
Part 1: Specifications*: the exponential time-weighting detector (clause 3.8)
with the F and S time constants (clause 5.7), and the 4 kHz toneburst reference
responses of Table 4 (class 1 acceptance limits) used to verify the ballistics
in CI.

---


<!-- source: docs/levels.md | canonical: https://jmrplens.github.io/phonometry/guides/levels/ -->

# Integrated and Statistical Levels

Environmental noise metrics computed directly from the raw (calibrated) signal.

## Leq and LAeq

The equivalent continuous level integrates the squared pressure over the
measurement time:

$$
L_{eq} = 10\log_{10}\left(\frac{1}{T}\int_0^T \frac{p^2(t)}{p_0^2}\ dt\right) \text{ dB}, \qquad p_0 = 20\ \mu\text{Pa}
$$

and $L_{Aeq}$ is the same integral after A-weighting the signal. $L_N$ is the
level exceeded $N\ \%$ of the time: the $(100-N)$-th percentile of the
time-weighted level distribution.

```python
import numpy as np
from phonometry import metrology

# 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)
sensitivity = 1.0                                    # calibration_factor (see Calibration)

# Equivalent continuous level of the whole recording
level = metrology.leq(recording, calibration_factor=sensitivity)

# A-weighted Leq (the standard environmental noise metric)
la = metrology.laeq(recording, fs, calibration_factor=sensitivity)
```

Both accept 1D signals (returning a scalar) or 2D `[channels, samples]` arrays
(returning one level per channel), and support `dbfs=True` for digital
full-scale analysis (calibration does not apply in dBFS mode).

Why the *energy* mean and not the arithmetic mean of dB values? Because sound
doses add as energy: two periods at 60 dB and 80 dB do not average to 70 dB;
the 80 dB half dominates and $L_{eq}$ = 77 dB. Averaging decibels directly
underestimates every fluctuating noise. $L_{eq}$ is the level of the *steady*
sound carrying the same energy as the real, fluctuating one, which is why
regulations are written in terms of it.

The same rule governs every combination of levels: period levels into a
whole-day value, microphone positions into a room average, repeated
measurements into a mean. Combine energies,
`10 * np.log10(np.mean(10 ** (L / 10)))`, never the dB values. The
arithmetic-mean error is one-sided (it always under-reads) and grows with
the spread, so it does not cancel out over many measurements: with values
spread over 10 dB it already costs a couple of decibels. The few normative
formulas that do average decibels directly are deliberate approximations and
say so (ISO 1996-2 offers one as a substitute for repeated-measurement
uncertainty and warns it inflates once levels spread beyond 3 dB, see the
uncertainty section below); everywhere else, energy.

### `leq()` / `laeq()` parameters

| Parameter | Type / shape | Units | Range / default | Notes |
| :--- | :--- | :--- | :--- | :--- |
| `x` | 1D or 2D array | digital units (or Pa if calibrated) | non-empty | 2D is `[channels, samples]`; returns one level per channel |
| `fs` | int | Hz | > 0 (`laeq` only) | `leq` needs no sample rate (pure RMS integral) |
| `calibration_factor` | float | Pa per digital unit | default `1.0` | From `sensitivity()` |
| `dbfs` | bool | — | default `False` | `True`: 0 dBFS = full-scale RMS sine; ignores calibration |

## Percentile levels (LN)

`ln_levels` computes statistical levels from the time-weighted envelope:
**L10** is the level exceeded 10 % of the time (event peaks), **L50** the median,
**L90** the background level.

```python
import numpy as np
from phonometry import metrology

# A steady tone gives L10 = L50 = L90; percentiles only tell a story for a
# *fluctuating* level. Synthesize 3 s alternating between a quiet and a
# ~10 dB louder half-second so the statistics separate.
fs = 48000
rng = np.random.default_rng(0)
segment = fs // 2                                  # 0.5 s per level
quiet = 0.02 * rng.standard_normal(segment)        # background
loud = 0.06 * rng.standard_normal(segment)         # ~10 dB louder events
varying = np.tile(np.concatenate([quiet, loud]), 3)

stats = metrology.ln_levels(varying, fs, n=(10, 50, 90), weighting="A")
print(f"LA10={stats[10]:.1f}  LA50={stats[50]:.1f}  LA90={stats[90]:.1f} dB")
# LA10=66.6  LA50=65.2  LA90=58.5 dB  -> L10 (events) > L50 (median) > L90 (background)
```

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/ln_levels_example_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/ln_levels_example.svg" alt="Fast level history of fluctuating noise with the L10, L50 and L90 statistical levels marked" width="80%"></picture>

*L10 tracks the event peaks, L50 the median level and L90 the background.*

<details>
<summary>Show the code for this figure</summary>

```python
import numpy as np
import matplotlib.pyplot as plt
from phonometry import metrology

# The fluctuating signal of the ln_levels example: 0.5 s of background
# alternating with 0.5 s of ~10 dB louder events, repeated 3 times
fs = 48000
rng = np.random.default_rng(0)
segment = fs // 2
quiet = 0.02 * rng.standard_normal(segment)
loud = 0.06 * rng.standard_normal(segment)
varying = np.tile(np.concatenate([quiet, loud]), 3)

# Fast mean-square envelope -> level vs time, plus the percentile levels
envelope = metrology.time_weighting(varying, fs, mode="fast")
level_t = 10 * np.log10(np.maximum(envelope, 1e-12) / (2e-5) ** 2)
stats = metrology.ln_levels(varying, fs, n=(10, 50, 90))
t = np.arange(varying.size) / fs

fig, ax = plt.subplots()
ax.plot(t, level_t, linewidth=0.8, label="Fast level Lp(t)")
for i, (n_value, style) in enumerate([(10, "--"), (50, "-"), (90, "-.")], 1):
    ax.axhline(float(stats[n_value]), color=f"C{i}", linestyle=style,
               label=f"L{n_value} = {stats[n_value]:.1f} dB")
ax.set(xlabel="Time [s]", ylabel="Level [dB]")
ax.legend(loc="lower right")
plt.show()
```

</details>

Options: `mode` selects the envelope ballistics (`'fast'`, `'slow'`,
`'impulse'`), `weighting` applies A/C weighting first, and
`calibration_factor`/`dbfs` behave as in `leq`. The integrator attack transient
(~5τ) is discarded before taking percentiles, so the leading settling ramp is
not counted in the low percentiles.

Formally, $L_N$ is the $(100-N)$-th percentile of the distribution of the
time-weighted level: the recording is first turned into a level-vs-time
envelope (Fast by default), and $L_{10}$ is the envelope value exceeded 10 %
of the time. That makes the *ballistics choice part of the metric*: an
$L_{10}$ from a Slow envelope is systematically lower than from a Fast one on
impulsive noise, so regulations always name the time weighting.

### Reading Leq against the percentiles

$L_{eq}$ and the $L_N$ family answer different questions about the same
level history. $L_{eq}$ is an energy mean, so the loudest moments dominate
it: a single second at 100 dB lifts the $L_{eq}$ of an otherwise steady
60 dB hour to about 66 dB, while $L_{90}$, $L_{50}$ and even $L_{10}$ barely
move (a one-second event occupies far less than 10 % of the hour).
Percentiles are rank statistics, robust against rare events by construction.
In practice:

- **$L_{eq}$ (and $L_{Aeq}$)** is the dose metric: regulations, exposure
  and annoyance models are written in it precisely *because* it refuses to
  ignore rare loud events.
- **$L_{90}$** estimates the residual (background) level under an
  intermittent source, which is how ISO 1996-2 Annex I uses it.
- **$L_{10}$** tracks event peaks; the spread $L_{10} - L_{90}$ is a quick
  intermittency indicator.
- **$L_{eq} - L_{50}$** measures how "peaky" the history is: for steady
  noise the two nearly coincide, and the more the level fluctuates the
  further $L_{eq}$ climbs above the median (for a Gaussian level
  distribution with standard deviation $\sigma$ dB,
  $L_{eq} \approx L_{50} + 0.115\,\sigma^2$).

One caution: percentiles do not combine. Two hours with known $L_{90}$
values do not yield the two-hour $L_{90}$ by any formula; recompute it from
the pooled envelope. $L_{eq}$ values, by contrast, combine exactly by
time-weighted energy averaging, which is what `composite_rating_level`
does below.

### `ln_levels()` parameters

| Parameter | Type / shape | Units | Range / default | Notes |
| :--- | :--- | :--- | :--- | :--- |
| `x` | 1D or 2D array | digital units | non-empty | 2D returns per-channel dicts |
| `fs` | int | Hz | > 0 | Needed by the envelope detector |
| `n` | tuple of ints | % | default `(10, 50, 90)` | Any exceedance percentages, e.g. `(1, 5, 95)` |
| `mode` | str | — | `'fast'` (default), `'slow'`, `'impulse'` | IEC 61672-1 ballistics of the envelope |
| `weighting` | str or None | — | `'A'`, `'C'`, `'G'`, `'Z'`, `None` (default) | Frequency weighting before the envelope |
| `calibration_factor` / `dbfs` | float / bool | — | as `leq` | Same semantics as in `leq()` |

## Peak, event and occupational metrics

```python
import numpy as np
from phonometry import metrology

# 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)
sensitivity = 1.0                                    # calibration_factor (see Calibration)

# C-weighted peak (IEC 61672-1 §5.13) - occupational action limits use this
peak = metrology.lc_peak(recording, fs, calibration_factor=sensitivity)

# A single noise event and a work-shift sample (slices of a real recording)
event = recording
shift_sample = recording

# Sound exposure level: single-event level normalized to 1 s (LAE)
lae = metrology.sel(event, fs, weighting="A", calibration_factor=sensitivity)

# Daily noise dose (IEC 61252): exposure in Pa²·h and LEX,8h / LEP,d
E = metrology.sound_exposure(shift_sample, fs, duration_hours=8, calibration_factor=sensitivity)
lex = metrology.lex_8h(shift_sample, fs, duration_hours=8, calibration_factor=sensitivity)
```

`lc_peak` is verified against the one-cycle/half-cycle reference responses of
IEC 61672-1:2013 Table 5, `sel` against the Table 4 LAE toneburst column, and
the dose functions against the IEC 61252 anchors (3.2 Pa²h ↔ exactly 90 dB).
`lc_peak` polyphase-oversamples the C-weighted signal by `oversample` (default
`8`) before taking the maximum, recovering the true inter-sample peak: a raw
on-grid maximum under-reads sustained HF tones by up to ~1.15 dB (an 8 kHz tone
at 48 kHz is only 6 samples/cycle). Set `oversample=1` to detect the peak on the
original sample grid. With `duration_hours`, the input is treated as a
representative sample of that exposure period; without it, the input is the
whole event.

### SEL: comparing events of different duration

A 4 s train pass-by and a 30 s one cannot be compared by their $L_{Aeq}$
alone: the longer event delivers more energy at the same level. The **sound
exposure level** compresses the *whole* event energy into exactly one second:

$$
L_E = L_{eq,T} + 10\log_{10}\frac{T}{T_0}, \qquad T_0 = 1\ \text{s}
$$

so events of any duration become directly comparable, and $N$ identical
events sum as $+10\log_{10}N$. This is the building block of airport and
railway noise models.

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/sel_concept_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/sel_concept.svg" alt="A vehicle pass-by level history with its Leq over the whole event and the equal-energy one-second SEL block" width="80%"></picture>

<details>
<summary>Show the code for this figure</summary>

```python
import numpy as np
import matplotlib.pyplot as plt
from phonometry import metrology

# A vehicle pass-by: noise under a gaussian energy envelope (dBFS analysis)
fs = 48000
t = np.arange(int(8.0 * fs)) / fs
rng = np.random.default_rng(11)
x = 0.3 * np.exp(-0.5 * ((t - 4.0) / 1.1) ** 2) * rng.standard_normal(t.size)

level = 10 * np.log10(np.maximum(metrology.time_weighting(x, fs, mode="fast"), 1e-12))
l_sel = float(metrology.sel(x, fs, dbfs=True))
l_eq = float(metrology.leq(x, dbfs=True))
print(f"Leq = {l_eq:.1f} dBFS, SEL = {l_sel:.1f} dBFS")
# Leq = -16.6 dBFS, SEL = -7.6 dBFS -> the 1 s block carries the event energy

fig, ax = plt.subplots()
ax.plot(t, level, linewidth=1.0, label="Fast level of the event")
ax.hlines(l_eq, 0, 8, color="C2", linestyle="--",
          label=f"Leq over the whole event = {l_eq:.1f} dBFS")
ax.fill_between([3.5, 4.5], -55, l_sel, color="C1", alpha=0.25)
ax.hlines(l_sel, 3.5, 4.5, color="C1", linewidth=2,
          label=f"SEL = {l_sel:.1f} dBFS: same energy in 1 s")
ax.set(xlabel="Time [s]", ylabel="Level [dBFS]", ylim=(-55, l_sel + 6))
ax.legend(loc="lower left")
plt.show()
```

</details>

### Noise dose: sound exposure and LEX,8h

Occupational regulations limit the daily *dose*, not the level. IEC 61252
expresses it as **sound exposure** $E$ in pascal-squared-hours (the time
integral of the squared A-weighted pressure) and the equivalent
**normalized 8 h level**:

$$
E = \int_0^T p_A^2(t)\ dt \quad [\text{Pa}^2\text{h}], \qquad
L_{EX,8h} = 10\log_{10}\frac{E}{8\ \text{h} \cdot p_0^2}
$$

The anchor worth memorizing: **3.2 Pa²h ⇔ exactly 90 dB over 8 h** (the CI
suite enforces it). Half the dose is −3 dB; double duration at the same level
is +3 dB.

### Peak / event / dose parameters

| Function | Key parameters | Returns | Standard anchor |
| :--- | :--- | :--- | :--- |
| `lc_peak(x, fs, calibration_factor=1.0, dbfs=False)` | `dbfs=True` references full-scale *peak* (1.0), not RMS | LCpeak [dB] | IEC 61672-1 §5.13, Table 5 tone bursts |
| `sel(x, fs, weighting=None, ...)` | `weighting='A'` gives LAE | SEL [dB] | IEC 61672-1 Table 4 (LAE column) |
| `sound_exposure(x, fs, duration_hours=None, ...)` | `duration_hours` treats `x` as a sample of that period | E [Pa²h] | IEC 61252 |
| `lex_8h(x, fs, duration_hours=None, ...)` | same sampling semantics | LEX,8h [dB] | IEC 61252 (≡ LEP,d) |

`lex_8h` rates *one* recording; assembling a full working day from task or
job samples, with the normative ISO 9612 uncertainty budget, continues in
[Occupational Noise Exposure](https://jmrplens.github.io/phonometry/guides/occupational-exposure/).

## Environmental noise: Lden, Ldn and rating levels (ISO 1996-1)

Regulatory noise assessment weights evenings and nights more heavily.
`lden()` implements the day-evening-night level of ISO 1996-1:2016 (3.6.4:
+5 dB evening, +10 dB night, default 12/4/8 h periods, adjustable because
countries define them differently), `ldn()` the day-night variant (3.6.5),
and `composite_rating_level()` the general whole-day composite of clause 6.5
(Formulae 5-6) for arbitrary periods with source or character adjustments
(Table A.1: e.g. +5 dB regular impulsive, +12 dB highly impulsive, +3 to
+6 dB prominent tones):

```python
from phonometry import environmental

l = environmental.lden(63.2, 58.1, 51.4)                      # from LAeq per period
r = environmental.composite_rating_level([(63.2, 12, 0.0),    # day
                            (58.1, 4, 5.0),     # evening (+5)
                            (51.4, 8, 10.0)])   # night  (+10) == environmental.lden
```

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/lden_profile_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/lden_profile.svg" alt="Synthetic 24-hour urban LAeq profile with day, evening and night bands, the +5 and +10 dB weighted period levels and the resulting Lden" width="80%"></picture>

<details>
<summary>Show the code for this figure</summary>

```python
import numpy as np
import matplotlib.pyplot as plt
from phonometry import environmental

# Synthetic hourly LAeq of an urban road (dB), hours 00 to 23
laeq_h = np.array([48, 46, 45, 45, 46, 50, 56, 64, 66, 65, 63, 63,
                   64, 63, 63, 64, 65, 66, 65, 64, 63, 62, 61, 50], dtype=float)

def period_leq(idx):
    return 10 * np.log10(np.mean(10 ** (laeq_h[idx] / 10)))  # energy mean

ld = period_leq(np.arange(7, 19))                # day 07-19
le = period_leq(np.arange(19, 23))               # evening 19-23
ln_ = period_leq(np.r_[23, np.arange(0, 7)])     # night 23-07
l_den = environmental.lden(ld, le, ln_)
print(f"Lden = {l_den:.1f} dB")   # Lden = 64.3 dB

fig, ax = plt.subplots()
ax.axvspan(19, 23, color="C1", alpha=0.15)                                # evening
ax.axvspan(23, 24, color="C0", alpha=0.15); ax.axvspan(0, 7, color="C0", alpha=0.15)
ax.step(np.arange(25), np.r_[laeq_h, laeq_h[-1]], where="post",
        color="0.3", label="Hourly LAeq")
ax.hlines(ld, 7, 19, color="C2", linestyle="--", label="Lday (+0 dB)")
ax.hlines(le + 5, 19, 23, color="C1", linestyle="--", label="Levening + 5 dB")
ax.hlines([ln_ + 10, ln_ + 10], [23, 0], [24, 7], color="C0",
          linestyle="--", label="Lnight + 10 dB")
ax.hlines(l_den, 0, 24, color="C3", linewidth=2, label=f"Lden = {l_den:.1f} dB")
ax.set(xlabel="Hour of day", ylabel="Level [dB]", xlim=(0, 24))
ax.legend(loc="upper left", fontsize=8, ncol=2)
plt.show()
```

</details>

### `lden()` / `ldn()` / `composite_rating_level()` parameters

| Function | Key parameters | Notes |
| :--- | :--- | :--- |
| `lden(lday, levening, lnight, hours=(12, 4, 8))` | period LAeq values [dB]; `hours` must sum to 24 | +5 dB evening, +10 dB night (3.6.4) |
| `ldn(lday, lnight, hours=(15, 9))` | | +10 dB night (3.6.5) |
| `composite_rating_level(periods)` | iterable of `(level_db, hours, adjustment_db)`; hours positive, finite and summing to 24 | General Formulae (5)-(6); adjustments per Table A.1 |

Where you put the microphone changes the number: ISO 1996-2 fixes the receiver positions and their façade corrections. The diagram is measurement context; apply the corrections to your levels before analysis:

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/diagram_env_measurement_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/diagram_env_measurement.svg" alt="Environmental noise measurement positions per ISO 1996-2: free field, 2 m from the facade and flush-mounted, with their corrections" width="92%"></picture>

Combine with `laeq()` per time period to go from recordings to Lden, and with
the `tone_to_noise_ratio()` / `prominence_ratio()` verdicts of
[Prominent Discrete Tones](https://jmrplens.github.io/phonometry/guides/tone-prominence/) to justify tonal adjustments.

## Determining levels: tonal adjustment, residual noise and uncertainty (ISO 1996-2)

ISO 1996-2:2017 is the **determination** part: how the measured level is turned
into a rating level and reported with its uncertainty. The rating-level *summation*
and the time-of-day penalties live in ISO 1996-1 (above); ISO 1996-2 supplies the
tonal adjustment, the residual-noise correction and the uncertainty budget.

**Tonal adjustment (engineering method, Annex C).** From the energy-summed tone
level $L_{pt}$ and the masking-noise level $L_{pn}$ in the critical band around a
tone, the audibility above the masking threshold is
$\Delta L_{ta} = L_{pt} - L_{pn} + 2 + \lg[1 + (f_c/502)^{2.5}]$ dB (Formula (C.3)),
and the adjustment is $K_t = 0$ for $\Delta L_{ta} < 4$, $K_t = \Delta L_{ta} - 4$
for $4 \le \Delta L_{ta} \le 10$ and $K_t = 6$ above (Formulae (C.4)–(C.6)). The
critical bandwidth is 100 Hz up to 500 Hz and 20 % of $f_c$ above (Table C.1).
The one-third-octave **survey method** (`tonal_seeking_survey`) flags a band
exceeding both neighbours by 15/8/5 dB (low/mid/high), and
`tonal_adjustment_from_mean_audibility` maps the ISO/PAS 20065 mean audibility to
$K_t$ (Table J.1).

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/tonal_audibility_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/tonal_audibility.svg" alt="ISO 1996-2 tonal adjustment Kt as a piecewise function of the tonal audibility: zero below 4 dB, rising linearly to 6 dB between 4 and 10 dB, and 6 dB above, with the four Annex C.5 worked examples and a mid-range tone marked" width="80%"></picture>

<details>
<summary>Show the code for this figure</summary>

```python
import matplotlib.pyplot as plt
from phonometry import environmental

# ISO 1996-2:2007 Annex C.5, Example 2 (two tones near 400 Hz):
res = environmental.assess_tonal_audibility(tone_level=54.1, masking_noise_level=45.2,
                              centre_frequency=430.0)
print(res.audibility, res.adjustment)   # ΔLta ≈ 11.1 dB -> Kt = 6 dB
res.plot()
plt.show()
```
</details>

**Residual-noise correction (Clause 10.4).** `residual_sound_correction()`
applies $L = 10\lg(10^{L'/10} - 10^{L_\text{res}/10})$ (Formula (16)). With a
residual within 3 dB of the measured level no correction is allowed: the
*uncorrected* measured level $L'$ is then the reportable value, as an upper
bound of the specific sound (exposed as `reportable_upper_bound`, with
`reliable=False`). `gaussian_residual_level()` estimates the residual from
percentile levels (Annex I) and rejects inverted percentile orderings.

**Measurement uncertainty (Clause 4, Annex F).** `combined_standard_uncertainty()`
forms $u = \sqrt{\sum (c_j u_j)^2}$ (Formula (2)) and
`environmental_expanded_uncertainty()` applies $k = 2$ (95 %) or $k = 1.3$ (80 %);
`residual_correction_uncertainty()` carries the residual-correction sensitivity
(Formulae (F.7)/(F.8)) and `uncertainty_from_repeated_measurements()` the
repeated-measurement standard uncertainty: the primary energy-domain route
(Formulae (17)+(19)), with the level-domain Note 2 substitute (Formula (20))
reported alongside as `approximate_uncertainty` and a warning when the levels
spread beyond 3 dB, where the substitute grossly inflates.

```python
from phonometry import environmental

# Tonal adjustment for a prominent tone:
kt = environmental.assess_tonal_audibility(54.1, 45.2, 430.0).adjustment      # 6 dB

# Subtract residual (background) noise from a measured level:
corr = environmental.residual_sound_correction(measured_level=58.0, residual_level=50.0)
corr.corrected_level, corr.reliable

# Combine an uncertainty budget and expand to 95 %:
u = environmental.combined_standard_uncertainty([0.59, 0.3, 2.0, 0.40, 0.38])  # 2.18 dB (G.2)
environmental.expanded_uncertainty(u)                            # 4.36 dB (k = 2)
```

## Octave Spectrogram (levels over time)

Short-time fractional-octave analysis: one level per band per window,
time-aligned across bands.

```python
import numpy as np
from phonometry import metrology

# 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)

bank = metrology.OctaveFilterBank(fs=48000, fraction=3)
levels, freq, times = bank.spectrogram(recording, window_time=0.125, overlap=0.5)
# levels: (bands, frames) — ready for pcolormesh(times, freq, levels)
```

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/spectrogram_example_dark.webp"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/spectrogram_example.webp" alt="One-third-octave spectrogram of a logarithmic sweep with two tone bursts" width="80%"></picture>

*A logarithmic sweep plus two tone bursts, resolved in time and in standardized
1/3-octave bands.*

<details>
<summary>Show the code for this figure</summary>

```python
import numpy as np
import matplotlib.pyplot as plt
from scipy.signal import chirp
from phonometry import metrology

# Log sweep 80 Hz -> 8 kHz plus two tone bursts, in a little noise
fs = 48000
t = np.arange(int(4.0 * fs)) / fs
x = 0.5 * chirp(t, f0=80, t1=4.0, f1=8000, method="logarithmic")
x[int(1.0 * fs):int(1.3 * fs)] += np.sin(2 * np.pi * 4000 * t[: int(0.3 * fs)])
x[int(2.5 * fs):int(2.8 * fs)] += np.sin(2 * np.pi * 250 * t[: int(0.3 * fs)])
x += 0.01 * np.random.default_rng(42).standard_normal(t.size)

bank = metrology.OctaveFilterBank(fs=fs, fraction=3, order=6, limits=[50.0, 12000.0])
levels, freq, times = bank.spectrogram(x, window_time=0.125, overlap=0.5)

fig, ax = plt.subplots()
mesh = ax.pcolormesh(times, freq, levels, shading="auto")
ax.set_yscale("log")
ax.set(xlabel="Time [s]", ylabel="Frequency [Hz]")
fig.colorbar(mesh, label="Level [dB]")
plt.show()
```

</details>

- Multichannel input `(channels, samples)` returns `(channels, bands, frames)`.
- `times` holds each window's center in seconds.
- `mode='peak'` gives per-window peak-holding levels instead of RMS.
- `zero_phase=True` filters bands forward-backward so per-band group delay does
  not skew the frames (offline analysis only).

### `OctaveFilterBank.spectrogram()` parameters

| Parameter | Type | Units | Range / default | Notes |
| :--- | :--- | :--- | :--- | :--- |
| `x` | 1D or 2D array | digital units | non-empty | 2D returns `(channels, bands, frames)` |
| `window_time` | float | s | > 0; default `0.125` | Frame length (0.125 s mirrors Fast) |
| `overlap` | float | — | 0 ≤ overlap < 1; default `0.5` | Fraction of window overlap (0 = none) |
| `mode` | str | — | `'rms'` (default) or `'peak'` | Per-window detector |
| `detrend` | bool | — | default `True` | Remove each band's DC offset before the level (improves low-frequency accuracy) |
| `zero_phase` | bool | — | default `False` | Forward-backward filtering (offline only) |
| `calibration_factor` / `dbfs` | — | — | constructor-only | Set on `OctaveFilterBank(...)`, not per call |

See [Calibration and dBFS](https://jmrplens.github.io/phonometry/guides/calibration/) to convert digital units to physical
SPL, and [Time Weighting](https://jmrplens.github.io/phonometry/guides/time-weighting/) for the envelope details. The
ISO 9612 occupational strategies continue in
[Occupational Noise Exposure](https://jmrplens.github.io/phonometry/guides/occupational-exposure/), the ECMA-418-1
tonal-prominence verdicts in [Prominent Discrete Tones](https://jmrplens.github.io/phonometry/guides/tone-prominence/),
and the ISO 226 equal-loudness contours live with the perception metrics in
[Loudness](https://jmrplens.github.io/phonometry/guides/loudness/).

## References

- International Electrotechnical Commission. (2013). *Electroacoustics —
  Sound level meters — Part 1: Specifications* (IEC 61672-1:2013).
  [IEC webstore](https://webstore.iec.ch/en/publication/5708).
  The envelope ballistics behind the percentile levels, the C-weighted peak
  and the SEL toneburst references the implementation is verified against.
- Kinsler, L. E., Frey, A. R., Coppens, A. B., & Sanders, J. V. (2000).
  *Fundamentals of acoustics* (4th ed.). Wiley. ISBN 978-0-471-84789-2.
  [Publisher page](https://www.wiley.com/en-us/Fundamentals+of+Acoustics%2C+4th+Edition-p-9780471847892).
  The sound-pressure, energy and level definitions underneath Leq, SEL and
  the dose measures.

## Standards

IEC 61672-1:2013, *Electroacoustics — Sound level meters —
Part 1: Specifications*: the Fast/Slow/Impulse envelope ballistics behind
`ln_levels`, the C-weighted peak of §5.13 (verified against the Table 5 tone
bursts) and the sound exposure level verified against the Table 4 LAE column.
IEC 61252, *Electroacoustics — Specifications for personal sound exposure
meters*: the sound exposure E in Pa²h and the normalized 8 h level LEX,8h
(≡ LEP,d), anchored at 3.2 Pa²h ⇔ exactly 90 dB. ISO 1996-1:2016, *Acoustics —
Description, measurement and assessment of environmental noise — Part 1:
Basic quantities and assessment procedures*: Lden (3.6.4), Ldn (3.6.5) and
the composite whole-day rating level of clause 6.5 (Formulae 5-6, Table A.1
adjustments).

---


<!-- source: docs/occupational-exposure.md | canonical: https://jmrplens.github.io/phonometry/guides/occupational-exposure/ -->

# Occupational Noise Exposure (ISO 9612)

A working day is rarely measured in one take: the daily exposure level a
regulation acts on has to be assembled from *samples* of a real shift, and
reported with an uncertainty a hygienist can defend. `lex_8h` (in
[Levels](https://jmrplens.github.io/phonometry/guides/levels/)) turns *one* recording into a daily level. ISO 9612:2009,
the engineering method (accuracy grade 2), is the survey design *around* that
primitive: how to sample a real working day, how to combine the pieces, and how
to attach the normative uncertainty every occupational-hygiene report needs. The
`occupational_exposure` module adds the three **measurement strategies** and the
**Annex C** uncertainty budget on top of the energy-average machinery.

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/diagram_dosimeter_iso9612_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/diagram_dosimeter_iso9612.svg" alt="Left: a worker wearing a personal sound exposure meter (IEC 61252), its microphone mounted about 0.04 m above the shoulder and at least 0.1 m from the entrance of the most-exposed ear canal, per ISO 9612 Clause 12.3. Right: the three measurement strategies drawn as timelines over an eight-hour working day, task-based (the day split into labelled tasks, at least three samples plus a duration each), job-based (five or more random samples spread over the homogeneous exposure group) and full-day (the whole shift measured at least three times), all feeding the LEX,8h and its Annex C uncertainty, chosen by work pattern from Table B.1" width="94%"></picture>

## 1. The three measurement strategies (Clauses 9-11)

The *task-based* strategy (Clause 9) splits the nominal day into tasks, takes
$I \ge 3$ samples per task, and energy-sums the task contributions

$$
L_{EX,8h,m} = L_{p,A,eqT,m} + 10 \log_{10}(T_m/T_0), \qquad T_0 = 8\ \text{h},
$$

so a loud but short task contributes little. The *job-based* (Clause 10) and
*full-day* (Clause 11) strategies instead take $N \ge 5$ (or three whole-day)
random samples over a homogeneous exposure group and normalise the effective-day
duration. The daily level is the same either way; the strategies differ in how
the **uncertainty** is built.

**Choosing a strategy.** ISO 9612 Table B.1 picks the strategy from the *work
pattern*, not from convenience, and the trade-off is coverage against effort.
The **task-based** strategy is the recommended default when the day
decomposes into a small number of well-defined tasks: because it measures each
task separately, it explains *where* the dose comes from (the per-task
breakdown above) and lets a short, loud task be sampled properly without
dragging out the whole survey, but it needs a reliable work analysis, and its
uncertainty grows if the task durations are themselves uncertain. The
**job-based** strategy suits a mobile worker with an unpredictable pattern or
a homogeneous group doing "the same job": random samples across the group
average over the variability instead of resolving it, which is robust when the
day cannot be cleanly cut into tasks but blind to which activity dominates.
The **full-day** strategy (a worn dosimeter capturing the entire shift,
repeated on several days) needs the least analysis and captures everything
including the unexpected, at the cost of the most wearer-days and the weakest
diagnostic value (one number per day, no breakdown, and false contributions
like knocks on the microphone are hard to spot). As a rule: resolve the dose
with task-based when you can, fall back to job-based for irregular work, and
use full-day when the pattern defies description or an independent whole-shift
check is wanted.

```python
from phonometry import hearing

# ISO 9612 Annex D — a welder's day split into three tasks. Each task level is
# the energy average of its Lp,A,eqT samples; durations carry a measured range.
tasks = [
    hearing.Task(samples=(70.0,), duration_hours=1.5, label="planning/breaks"),
    hearing.Task(samples=(80.1, 82.2, 79.6), duration_hours=5.0,
         duration_range=(4.0, 6.0), label="welding"),
    hearing.Task(samples=(86.5, 92.4, 89.3, 93.2, 87.8, 86.2), duration_hours=1.5,
         duration_range=(1.0, 2.0), label="cutting/grinding"),
]
res = hearing.task_based_exposure(tasks, include_duration_uncertainty=False, warn=False)
print(f"LEX,8h = {res.lex_8h:.1f} dB   U = {res.expanded_uncertainty:.1f} dB")
# LEX,8h = 84.3 dB   U = 2.7 dB
print(f"one-sided 95 % upper limit LEX,8h + U = {res.upper_limit:.1f} dB")   # 87.0 dB
for t in res.tasks:
    print(f"  {t.label:<16} Lp,A,eqT = {t.lp_aeqt:5.1f}   contributes {t.lex_8h_contribution:5.1f} dB")
#   planning/breaks  Lp,A,eqT =  70.0   contributes  62.7 dB
#   welding          Lp,A,eqT =  80.8   contributes  78.7 dB
#   cutting/grinding Lp,A,eqT =  90.1   contributes  82.8 dB

# The same shift measured job-based (Annex E) and full-day (Annex F): both use
# the Eq C.9 / Table C.4 sampling budget with k = 1.65 (one-sided 95 %).
job = hearing.job_based_exposure([88.1, 86.1, 89.7, 86.5, 91.1, 86.7], effective_duration_hours=7.5)
full = hearing.full_day_exposure([88.0, 91.9, 87.6, 90.4, 89.0, 88.4], effective_duration_hours=9.25)
print(f"job      LEX,8h = {job.lex_8h:.1f} dB   U = {job.expanded_uncertainty:.1f} dB")
# job      LEX,8h = 88.2 dB   U = 3.8 dB
print(f"full-day LEX,8h = {full.lex_8h:.1f} dB   U = {full.expanded_uncertainty:.1f} dB")
# full-day LEX,8h = 90.1 dB   U = 3.4 dB
```

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/exposure_uncertainty_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/exposure_uncertainty.svg" alt="ISO 9612 Annex D task-based exposure: the three task LEX,8h contributions as bars, the energy-summed daily LEX,8h line and the one-sided 95 % upper limit LEX,8h + U band above it" width="80%"></picture>

<details>
<summary>Show the code for this figure</summary>

```python
import matplotlib.pyplot as plt
from phonometry import hearing

# The ISO 9612 Annex D welder's day of the previous snippet.
tasks = [
    hearing.Task(samples=(70.0,), duration_hours=1.5, label="planning/breaks"),
    hearing.Task(samples=(80.1, 82.2, 79.6), duration_hours=5.0,
         duration_range=(4.0, 6.0), label="welding"),
    hearing.Task(samples=(86.5, 92.4, 89.3, 93.2, 87.8, 86.2), duration_hours=1.5,
         duration_range=(1.0, 2.0), label="cutting/grinding"),
]
res = hearing.task_based_exposure(tasks, include_duration_uncertainty=False, warn=False)

# One line: task contribution bars plus the LEX,8h and LEX,8h + U lines.
res.plot()
plt.show()
```

</details>

## 2. The Annex C uncertainty budget

Two subtleties are worth spelling out. First, the coverage factor is
$k = 1.65$ for a **one-sided** 95 % interval (Clause 14), because a hygienist
cares only about the *upper* bound: `res.upper_limit` = $L_{EX,8h} + U$ is the
value 95 % of measurements fall below, the number compared against an action
limit. Second, the task and job methods weight the *same* spread of samples
differently. The task sampling uncertainty $u_{1a}$ (Eq. C.6) divides the summed
squared deviations by $I(I-1)$ (the standard error of the mean, smaller by a
factor $\sqrt{I}$), whereas the job/full-day sampling uncertainty $u_1$ (Eq. C.12)
is the plain sample standard deviation with denominator $N-1$, whose contribution
$c_1 u_1$ is then read from **Table C.4** as a function of $(N, u_1)$. The same
raw scatter therefore inflates the job estimate more, which is the standard's
built-in penalty for coarser, fewer samples. (The printed job $L_{EX,8h}$ is
$88.2$ dB where Annex E reports $88.1$: the standard rounds the effective-day
level to $88.4$ before the duration normalisation; the library keeps it
unrounded.)

**What dominates the budget.** Annex C combines four sources in quadrature
(Table C.1): the **sampling** uncertainty ($u_{1a}$/$u_1$), the **duration**
uncertainty ($u_{1b}$, task-based only), the **instrument** ($u_2$, Table C.5)
and the **microphone position** ($u_3$, Clause C.6). The last two are small
and roughly fixed ($u_2 = 0.7$ dB for a class 1 sound level meter, 1.5 dB for
a class 2 meter or a personal exposimeter, and $u_3 = 1.0$ dB by default), so
in practice the **sampling term almost always dominates**: it scales with the
scatter of the measured levels, which in a real workplace easily reaches
several decibels and, entered in quadrature, swamps the sub-decibel
instrument and position terms. The practical consequence: a quadrature
budget is set by its largest term, so tightening the instrument grade buys
little once sampling scatter is large; the productive
move is *more samples* (the standard error falls as $1/\sqrt{I}$ or $1/\sqrt{N}$),
which is exactly what the Clause 9.3 / 10.4 advisories nudge you toward.
Because peak $L_{p,Cpeak}$ carries no Annex C sampling model (Table C.5,
Note 1), it is reported without an uncertainty, not with a zero one.

When a task's samples span **3 dB or more** (Clause 9.3), or the job contribution
$c_1 u_1$ exceeds 3.5 dB (Clause 10.4), or too few workers are covered
(Table 1 cumulative-duration), the result sets `sampling_advisory=True` and, with
`warn=True`, emits an `OccupationalExposureWarning` recommending more measurements. Peak
levels $L_{p,Cpeak}$ are reported **without** an uncertainty: Annex C gives no
method for them (Table C.5, Note 1), so peak-uncertainty is out of scope. The
three Annex D/E/F worked examples above are reproduced to the standard's printed
precision (Annex E's final rounding is disclosed above), and the theory is
derived on the [Theory](https://jmrplens.github.io/phonometry/reference/theory/environment-transport/) page.

### `task_based_exposure()` / `job_based_exposure()` / `full_day_exposure()` parameters

| Parameter | Applies to | Type | Units | Range / default | Notes |
| :--- | :--- | :--- | :--- | :--- | :--- |
| `tasks` | task | list of `Task` | — | ≥ 1 | Each `Task` has `samples`, `duration_hours`, optional `duration_range`/`duration_samples`, `label`, `instrument` |
| `samples` | job / full-day | sequence | dB | ≥ 2 (≥ 5 / ≥ 3 advised) | Random `Lp,A,eqT` samples |
| `effective_duration_hours` | job / full-day | float | h | > 0 | Effective working-day duration $T_e$ |
| `instrument` | all | str | — | `'class1'`, `'class2'`, `'personal_exposimeter'` (default) | Selects $u_2$ (Table C.5) |
| `u3` | all | float | dB | default `1.0` | Microphone-position uncertainty (Clause C.6) |
| `include_duration_uncertainty` | task | bool | — | default `True` | `False` omits the $(c_{1b}u_{1b})^2$ term (Annex D case a) |
| `n_workers` / `sample_duration_hours` | job | int / float | — / h | default `None` | Table 1 cumulative-duration check |
| `warn` | all | bool | — | default `True` | Emit `OccupationalExposureWarning` for the sampling advisories |

All three return an `ExposureResult` with `lex_8h`, `combined_standard_uncertainty`
$u$, `expanded_uncertainty` $U = 1.65\ u$, `upper_limit` = $L_{EX,8h} + U$,
`sampling_advisory`, and (task-based) the per-task `tasks` breakdown; the
result's `.plot()` draws the per-task contribution bars with the $L_{EX,8h}$
and upper-limit lines (task-based results only, since the other strategies
carry no per-task breakdown).

## 3. The measurement report (`.report()`)

ISO 9612 Clause 15 lists what the measurement report shall state, down to the
requirement that the noise exposure level and the measurement uncertainty be
reported as *separate values*, each rounded to one decimal place.
`ExposureResult.report(path)` writes that report as a one-page PDF fiche laid
out like a prevention-service measurement sheet: the standard-basis line naming
the applied strategy, a header grid (company via `client`, worker(s)/job via
`specimen`, workplace via `test_room`, and the Clause 15 c `instrumentation`
and `calibration` free-text fields of `ReportMetadata`), the work analysis
(the per-task table with the contribution chart for a task-based result, or
the sampling summary with the Formula C.9 budget for job-based/full-day), the
boxed $L_{EX,8h}$ with $U$, $k = 1.65$ and the one-sided 95 % upper limit, an
assessment table against the exposure action values (80/85 dB(A)) and the
exposure limit value (87 dB(A)) of Directive 2003/10/EC with a PASS/FAIL
verdict against the limit value, and a footer identity block. A printed note
records that the limit value applies to the effective exposure with the worn
hearing protectors' attenuation taken into account, which the measured
$L_{EX,8h}$ does not include. `verbose=True` adds the per-task Annex C
uncertainty columns ($u_{1a}$, $u_{1b}$, $u_2$); `language="es"` renders the
Spanish fiche (nivel de exposición diario equivalente, comma decimals).
Rendering needs the optional `phonometry[report]` extra (reportlab), plus
matplotlib for the task-based chart.

```python
from phonometry import hearing, ReportMetadata

res = hearing.task_based_exposure(tasks, warn=False)  # the Annex D day above
res.report(
    "lex8h.pdf",
    metadata=ReportMetadata(
        client="Example fabrication works",
        specimen="Welders (homogeneous exposure group, 4 workers)",
        test_room="Steel assembly hall, line 2",
        instrumentation="Personal sound exposure meter (IEC 61252), s/n 0042",
        calibration="Calibrator IEC 60942 class 1; field checks within 0.3 dB",
        report_id="EXAMPLE-9612",
    ),
)   # LEX,8h = 84.3 dB, U = 3.2 dB -> lower action value exceeded, limit PASS
```

The rendered example fiche, regenerated with `make reports`, is kept in the
repository. Click the preview to open the PDF:

[![ISO 9612 occupational noise-exposure example report: a header with the company, exposure group, workplace and instrumentation/calibration, the task-based work-analysis table with durations, sample counts, task levels and LEX,8h contributions, the per-task contribution chart, the boxed LEX,8h = 84.3 dB with U = 3.2 dB, and the Directive 2003/10/EC assessment where the 80 dB(A) lower action value is exceeded and the 85/87 dB(A) values are not, ending in a PASS verdict](https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/reports/iso9612_exposure_example.webp)](https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/reports/iso9612_exposure_example.pdf)

*Occupational noise-exposure fiche (`ExposureResult.report`), the ISO 9612
Annex D task-based day with the Directive 2003/10/EC assessment.*

## See also

- [Levels](https://jmrplens.github.io/phonometry/guides/levels/): the `lex_8h` / `sound_exposure` dose primitives
  (IEC 61252) and the LCpeak these strategies report alongside.
- [Measurement uncertainty](gum-uncertainty.md): the GUM machinery behind
  combined and expanded uncertainties.
- [Theory](https://jmrplens.github.io/phonometry/reference/theory/environment-transport/): the derivation of the strategy formulas and the
  Annex C budget.
- API reference: [`hearing.occupational_exposure`](https://jmrplens.github.io/phonometry/reference/api/hearing/occupational-exposure/).

## References

- International Organization for Standardization. (2009). *Acoustics —
  Determination of occupational noise exposure — Engineering method*
  (ISO 9612:2009). [iso.org catalogue](https://www.iso.org/standard/41718.html).
  The implemented method: the three strategies, the microphone placement of
  Clause 12.3, the Annex C uncertainty budget and the Annex D/E/F worked
  examples.
- European Parliament and Council. (2003). *Directive 2003/10/EC on the
  minimum health and safety requirements regarding the exposure of workers to
  the risks arising from physical agents (noise)*. Official Journal of the
  European Union.
  [eur-lex.europa.eu](https://eur-lex.europa.eu/eli/dir/2003/10/oj/eng).
  The EU exposure action values (80/85 dB) and limit value (87 dB) that the
  `LEX,8h` and its upper limit are assessed against.
- National Institute for Occupational Safety and Health. (1998). *Criteria for
  a recommended standard: Occupational noise exposure — Revised criteria 1998*
  (DHHS/NIOSH Publication No. 98-126).
  [doi:10.26616/NIOSHPUB98126](https://doi.org/10.26616/NIOSHPUB98126),
  [free PDF](https://www.cdc.gov/niosh/docs/98-126/pdfs/98-126.pdf).
  The freely available criteria document behind the 85 dB(A) recommended
  exposure limit and the hearing-conservation rationale for measuring the
  daily dose.

## Standards

ISO 9612:2009, *Acoustics — Determination of occupational noise
exposure — Engineering method*: the task-based (Clause 9), job-based
(Clause 10) and full-day (Clause 11) strategies, the Annex C uncertainty budget
(Formulae C.6, C.9 and C.12, Tables C.4/C.5) and the one-sided coverage factor
k = 1.65 (Clause 14), validated against the worked examples of Annexes D, E
and F.

---


<!-- source: docs/tone-prominence.md | canonical: https://jmrplens.github.io/phonometry/guides/tone-prominence/ -->

# Prominent Discrete Tones (ECMA-418-1)

Tonal components in machinery noise are far more annoying than their level
suggests. ECMA-418-1:2024 (referenced by ECMA-74 Annex D) gives two FFT-based
methods to decide whether a discrete tone is *prominent*:
`tone_to_noise_ratio()` compares the tone level with the masking noise in its
critical band (clause 11), and `prominence_ratio()` compares the critical band
centred on the tone with the two contiguous bands (clause 12). Both return a
structured verdict against the frequency-dependent prominence criteria.

## 1. Tone-to-noise ratio and prominence ratio

```python
import numpy as np
from phonometry import psychoacoustics

fs = 48000
rng = np.random.default_rng(0)
t = np.arange(fs) / fs
x = np.sin(2 * np.pi * 1000 * t) + 0.05 * rng.standard_normal(fs)  # 1 kHz tone in noise
tnr = psychoacoustics.tone_to_noise_ratio(x, fs)            # highest peak, or tone_freq=...
pr = psychoacoustics.prominence_ratio(x, fs, tone_freq=1000.0)
print(tnr.ratio_db, tnr.criterion_db, tnr.prominent)
```

The methods hinge on the **critical band**, the ear's analysis bandwidth,
$\Delta f_c = 25 + 75\ [1 + 1.4(f/1000)^2]^{0.69}$ Hz (162 Hz at 1 kHz): a
tone is masked only by the noise *inside* its critical band, so both methods
focus on that band rather than the whole spectrum, but they use it
differently. The tone-to-noise ratio works within the band, separating its
spectral lines into tone and noise and subtracting their levels (clause 11,
Formulae 9–11); the prominence ratio instead compares the *whole* band centred
on the tone with the mean of its two contiguous critical bands (clause 12,
Formula 23):

$$
\mathrm{TNR} = L_t - L_n, \qquad
\mathrm{PR} = 10\,\lg\!\frac{W_M}{\tfrac{1}{2}(W_L + W_U)}\ \text{dB},
$$

where $L_t$ is the tone level (the energy sum of the tonal lines above the
band-edge baseline, Formula 9), $L_n$ the level of the masking noise that
remains in the critical band, rescaled to the full critical bandwidth
(Formulae 10–11), and $W_M$, $W_L$, $W_U$ the powers in the middle, lower and
upper critical bands (below $f_t = 171.4$ Hz the truncated lower band is
rescaled to a 100 Hz bandwidth, Formula 24).

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/tonality_spectrum_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/tonality_spectrum.svg" alt="Averaged spectrum of a tone in noise with the critical band shaded and the tone-to-noise ratio annotated against its prominence criterion" width="80%"></picture>

<details>
<summary>Show the code for this figure</summary>

```python
import numpy as np
import matplotlib.pyplot as plt
from scipy.signal import welch
from phonometry import psychoacoustics

fs = 48000
rng = np.random.default_rng(21)
t = np.arange(30 * fs) / fs
x = (np.sqrt(2) * 0.1 * np.sin(2 * np.pi * 1000 * t)
     + 0.05 * rng.standard_normal(t.size))
res = psychoacoustics.tone_to_noise_ratio(x, fs)

# Averaged 1 Hz Hann spectrum (the clause 11.1 front end) and the
# critical band about the detected tone (edges approximated as +/- dfc/2):
f, p = welch(x, fs, window="hann", nperseg=fs, scaling="spectrum")
dfc = 25 + 75 * (1 + 1.4 * (res.frequency / 1000) ** 2) ** 0.69
sel = (f > 700) & (f < 1400)
plt.plot(f[sel], 10 * np.log10(p[sel]))
plt.axvspan(res.frequency - dfc / 2, res.frequency + dfc / 2, alpha=0.15)
plt.title(f"TNR = {res.ratio_db:.1f} dB (criterion {res.criterion_db:.1f} dB)")
plt.xlabel("Frequency [Hz]"); plt.ylabel("Bin power [dB]")
plt.show()
```

</details>

A TNR at or above $8 + 8.33\log_{10}(1000/f_t)$ dB below 1 kHz (a flat 8 dB for
$f_t \ge 1$ kHz) classifies the tone as *prominent*; the PR criterion is
$9 + 10\log_{10}(1000/f_t)$ dB below 1 kHz and 9 dB from there up, likewise
applied with $\ge$. Low frequencies get higher thresholds because wider
relative bands mask more.

**When the two ratios disagree.** Near the criteria the verdicts can differ,
because each ratio is fragile in a different situation. TNR has to *split*
the critical band into tonal and noise lines first, so it degrades when that
separation is ambiguous: a tone riding a steep noise slope, or closely
spaced components whose skirts overlap. PR needs no separation, which makes
it the robust, automatable choice when **several tones share the critical
band** (they all land in $W_M$); in exchange it reads low when a
*neighbouring* band also carries a tone (the flanking bands are then not
noise) and is biased on sharply sloping spectra, where the two flanking
bands no longer estimate the masking at the tone. In practice: prefer TNR
for a clean, isolated tone, prefer PR for multi-tone complexes sharing a
band, and report both when they straddle their criteria.

## 2. Where to measure (ECMA-74) and practice

ECMA-74 (which delegates its tone assessments to ECMA-418-1) also fixes where to measure around a device (context only): phonometry implements the ECMA-418-1 assessments, not the ECMA-74 Annex D measurement procedure:

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/diagram_tonality_positions_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/diagram_tonality_positions.svg" alt="ECMA-74 emission measurement positions: seated operator microphone at 0.25 m and 1.20 m, and the four bystander positions at 1 m" width="92%"></picture>

Proximate secondary tones in the same critical band are combined per
clause 11.6; for harmonic complexes assess each component (`tone_freq=`).
Both methods work on Hann-windowed, RMS-averaged spectra and need no absolute
calibration (the ratios are level differences).

### `tone_to_noise_ratio()` / `prominence_ratio()` parameters

| Parameter | Type | Units | Range / default | Notes |
| :--- | :--- | :--- | :--- | :--- |
| `x` | 1D array | any (uncalibrated OK) | ≥ `fs/resolution_hz` samples | Ratios are level differences: calibration cancels out |
| `fs` | int | Hz | > 0 | |
| `tone_freq` | float, optional | Hz | 89.1–11 200; default `None` | `None` assesses the highest peak in the range of interest |
| `resolution_hz` | float | Hz | > 0; default `1.0` | Tone band must stay within 15 % of the critical band (clause 11.2) |

Both return a `ToneAssessment(frequency, ratio_db, criterion_db, prominent)`.

## See also

- [Levels](https://jmrplens.github.io/phonometry/guides/levels/): the ISO 1996-1 rating levels whose tonal adjustments
  (Table A.1) these prominence verdicts justify objectively.
- [Sound Quality Metrics](https://jmrplens.github.io/phonometry/guides/sound-quality/): the ECMA-418-2 psychoacoustic
  tonality T in tu_HMS, the hearing-model counterpart of these FFT ratios.
- [Impulsive-sound prominence](impulse-prominence.md): the NT ACOU 112
  counterpart for impulsive (rather than tonal) character.
- [Theory](https://jmrplens.github.io/phonometry/reference/theory/perception/): the critical-band model and criteria derivation.
- API reference: [`psychoacoustics.tonality`](https://jmrplens.github.io/phonometry/reference/api/psychoacoustics/tonality/).

## References

- Ecma International. (2024). *ECMA-418-1: Psychoacoustic metrics for ITT
  equipment — Part 1: Prominent discrete tones* (3rd ed.).
  [Free PDF](https://ecma-international.org/wp-content/uploads/ECMA-418-1_3rd_edition_december_2024.pdf).
  The implemented standard, freely downloadable: the TNR (clause 11) and PR
  (clause 12) methods, the critical-band model and the prominence criteria of
  section 1.
- Ecma International. (2025). *ECMA-74: Measurement of airborne noise emitted
  by information technology and telecommunications equipment* (22nd ed.).
  [Free PDF](https://ecma-international.org/wp-content/uploads/ECMA-74_22nd_edition_december_2025.pdf).
  The parent emission standard, freely downloadable: the operator/bystander
  measurement positions of section 2, with Annex D delegating the tone
  assessments to ECMA-418-1.

## Standards

ECMA-418-1:2024 (3rd edition), *Psychoacoustic metrics for ITT
equipment — Part 1: Prominent discrete tones* — the tone-to-noise ratio
(clause 11), the prominence ratio (clause 12), the critical-band model and the
frequency-dependent prominence criteria.

---


<!-- source: docs/loudness.md | canonical: https://jmrplens.github.io/phonometry/guides/loudness/ -->

# Loudness

Level metrics tell you how much *sound pressure* there is; loudness tells you
how loud a listener actually *perceives* it. This page covers the three
loudness model families phonometry ships: the Zwicker method (ISO 532-1), the
Moore-Glasberg methods (ISO 532-2/3) and the Sottek Hearing Model loudness
(ECMA-418-2), plus the equal-loudness contours of pure tones (ISO 226).
Sharpness, tonality and roughness live in
[Sound Quality Metrics](https://jmrplens.github.io/phonometry/guides/sound-quality/); speech metrics in
[Speech Transmission Index](https://jmrplens.github.io/phonometry/guides/speech-transmission/) and
[Speech Intelligibility Index](https://jmrplens.github.io/phonometry/guides/speech-intelligibility/).

## Loudness in sones (ISO 532-1, Zwicker)

Decibels compress perception: 10 dB more reads as *twice as loud*, and two
sounds with the same dB(A) can differ audibly depending on how their energy
spreads over the ear's **critical bands**. The Zwicker method models the
hearing chain explicitly (outer/middle-ear transmission, critical-band
analysis on the 24 Bark scale, level-dependent masking slopes) and outputs
**loudness N in sones**, a ratio scale: 4 sones is twice as loud as 2 sones.
By definition a 1 kHz tone at 40 dB SPL is 1 sone, and every +10 phon
doubles the sone value.

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/diagram_zwicker_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/diagram_zwicker.svg" alt="ISO 532-1 Zwicker loudness chain: 28 one-third-octave band levels, transmission and lower-critical-band grouping, core loudness of the 20 critical bands, specific loudness over Bark, integrated into total loudness N in sones and loudness level in phons" width="78%"></picture>

The animation below shows that integration at work: as the band level of a
1 kHz narrowband sound steps up, the specific-loudness pattern N'(z) grows
along the Bark axis and the area under it is the total loudness in sones.

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/anim_specific_loudness_dark.gif"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/anim_specific_loudness.gif" alt="Animation: the specific-loudness pattern of a 1 kHz narrowband sound builds along the Bark axis as the band level steps from 45 to 85 dB, and the area under the pattern integrates to the total loudness in sones" width="640" height="360" loading="lazy"></picture>

[Watch the high-resolution video (WebM)](https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/anim_specific_loudness.webm)

```python
import numpy as np
from phonometry import psychoacoustics

# A raw recording plus its calibration so the guide runs standalone
fs = 48000
x = 0.2 * np.sin(2 * np.pi * 1000 * np.arange(fs) / fs)   # any recording (digital units)
sens = 1.0                                                # calibration_factor to pascals
levels_28 = np.full(28, 60.0)                             # 28 one-third-octave band levels (dB)

# From a raw recording: calibration_factor scales digital units to Pa
res = psychoacoustics.loudness_zwicker(x, fs, field="free", calibration_factor=sens)
print(f"N = {res.loudness:.1f} sone  ({res.loudness_level:.0f} phon)")   # 13.1 sone (77 phon)

# Time-varying signals: percentile loudness N5 is the reporting standard
res = psychoacoustics.loudness_zwicker(x, fs)          # stationary=False (default)
print(f"{res.n5:.1f} {res.n10:.1f} {res.loudness:.1f}")   # 13.1 13.1 13.1 — N5, N10, Nmax

# From 28 one-third-octave band levels (25 Hz .. 12.5 kHz)
res = psychoacoustics.loudness_zwicker_from_spectrum(levels_28, field="diffuse")

res.plot()   # N'(z) over the Bark scale — the specific-loudness pattern (needs matplotlib)
```

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/loudness_pattern_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/loudness_pattern.svg" alt="Specific loudness patterns over the Bark scale for a 1 kHz narrowband sound and a broadband sound of equal band level" width="80%"></picture>

*Same band level, very different loudness: energy spread over many critical
bands (red) sums to far more sones than the same level concentrated in one
band (blue). The area under N'(z) is the total loudness.*

<details>
<summary>Show the code for this figure</summary>

```python
import matplotlib.pyplot as plt
import numpy as np
from phonometry import psychoacoustics

levels_28 = np.full(28, 60.0)                             # 28 one-third-octave band levels (dB)
# From 28 one-third-octave band levels (25 Hz .. 12.5 kHz)
res = psychoacoustics.loudness_zwicker_from_spectrum(levels_28, field="diffuse")

# One line — the specific-loudness pattern N'(z) straight from the result:
res.plot()
plt.show()

# Or reproduce the figure by hand — two patterns of equal band level (60 dB),
# energy spread over many critical bands vs concentrated in the 1 kHz band:
narrow = psychoacoustics.loudness_zwicker_from_spectrum(np.r_[np.full(16, -60.0), 60.0, np.full(11, -60.0)])
broad = psychoacoustics.loudness_zwicker_from_spectrum(np.full(28, 60.0))
z = np.arange(1, narrow.specific.size + 1) * 0.1          # Bark axis
fig, ax = plt.subplots()
for r, color, label in [
    (broad, "#ff7f0e", f"Broadband  N = {broad.loudness:.1f} sone"),
    (narrow, "#1f77b4", f"1 kHz narrowband  N = {narrow.loudness:.1f} sone"),
]:
    ax.fill_between(z, r.specific, color=color, alpha=0.3)
    ax.plot(z, r.specific, color=color, label=label)
ax.set_xlabel("Critical-band rate z [Bark]")
ax.set_ylabel("Specific loudness N' [sone/Bark]")
ax.legend()
plt.show()
```

</details>

The implementation is a clean-room port of the standard's **normative
reference program** (Annex A.4): all twelve data tables are digit-exact and
the full Annex B validation set runs in CI: the stationary test case
reproduces the published value to every printed digit, and the tone-pulse
N(t) traces stay inside the standard's per-sample 5 % tolerance band.

### `loudness_zwicker()` parameters

| Parameter | Type | Units | Range / default | Notes |
| :--- | :--- | :--- | :--- | :--- |
| `x` | 1D array | Pa (after calibration) | ≥ 8 ms at 48 kHz | Resampled internally to 48 kHz if needed |
| `fs` | int | Hz | > 0 | |
| `field` | str | — | `'free'` (default) / `'diffuse'` | Sound-field correction (Table A.5) |
| `stationary` | bool | — | default `False` | `True`: single N from the averaged spectrum |
| `calibration_factor` | float | Pa per digital unit | default `1.0` | From `sensitivity()` |

Returns a `ZwickerLoudness` dataclass: `loudness` (N, sones), `loudness_level`
(phon), `specific` (N′(z), 240 bins of 0.1 Bark), and for time-varying runs
`n5`, `n10`, `time`, `loudness_vs_time` (500 Hz trace).

### ISO 532-1 report (`.report()`)

`ZwickerLoudness.report(path)` renders a one-page PDF fiche laid out like an
accredited loudness report: a standard-basis line, an optional metadata header
block, a compact metrics table (total loudness *N*, loudness level *L*<sub>N</sub>,
and the *N*<sub>5</sub>/*N*<sub>10</sub> percentiles for a time-varying result)
beside the specific-loudness pattern *N*′(z) (the result's own `.plot()`), the
boxed `N = X sone (LN = Y phon)` single number, an optional verdict row and a
footer with the fixed disclaimer. It uses the same `ReportMetadata` container
(documented under [Field insulation](https://jmrplens.github.io/phonometry/guides/insulation-field/#report-metadata-reportmetadata))
and rendering engine as the ISO 717 insulation fiche; a supplied `requirement`
is read as the maximum permitted loudness in sone (a lower loudness passes).
Rendering needs reportlab (`pip install phonometry[report]`); 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")`.

```python
from phonometry import psychoacoustics, ReportMetadata

res = psychoacoustics.loudness_zwicker_from_spectrum(levels_28, field="free")
res.report(
    "loudness_fiche.pdf",
    metadata=ReportMetadata(
        specimen="Household appliance, steady operating noise",
        measurement_standard="ISO 532-1 method 1",
        laboratory="Phonometry Reference Laboratory",
        requirement=12.0,             # maximum permitted loudness (sone)
    ),
)                                     # N (sone) and LN (phon)
```

The example fiche, regenerated with `make reports`, is kept rendered in the
repository. Click the preview to open the PDF:

[![ISO 532-1 loudness example report: metadata header, a metrics table with total loudness N and loudness level LN, the specific-loudness pattern over Bark, the boxed N = 8.2 sone (LN = 70.4 phon) single number and a PASS verdict against a 12 sone limit](https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/reports/iso532_loudness_example.webp)](https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/reports/iso532_loudness_example.pdf)

*Zwicker loudness fiche (`ZwickerLoudness.report`), N in sone with LN in phon.*

## Loudness level of pure tones (ISO 226:2023)

The normal equal-loudness-level contours relate the SPL of a pure tone to its
perceived *loudness level* in phons (the SPL of an equally loud 1 kHz tone).
`equal_loudness_contour(phon)` evaluates ISO 226:2023 Formula (1) at the 29
preferred third-octave frequencies of Table 1, `loudness_level(spl, frequency)`
is the exact inverse (Formula 2), and `hearing_threshold()` returns the
threshold-of-hearing column. `equal_loudness_contours(phons)` bundles a whole
family of contours with the threshold into a plottable `EqualLoudnessContours`
result:

```python
from phonometry import psychoacoustics

freqs, spl = psychoacoustics.equal_loudness_contour(40.0)   # the classic 40-phon contour
phon = psychoacoustics.loudness_level(73.0, 63.0)           # 73 dB @ 63 Hz -> 40 phon

# The whole family (20-90 phon by default) plus the hearing threshold:
res = psychoacoustics.equal_loudness_contours()
res.plot()   # the iconic ISO 226 chart (needs matplotlib)
```

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/equal_loudness_contours_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/equal_loudness_contours.svg" alt="ISO 226:2023 normal equal-loudness-level contours from 20 to 90 phon with the hearing threshold curve" width="80%"></picture>

ISO 226:2023 defines the contours from 20 to 90 phon; above 80 phon the formula is valid only up to 4 kHz, so the 90 phon contour stops there and no higher contours are defined.

<details>
<summary>Show the code for this figure</summary>

```python
import matplotlib.pyplot as plt
from phonometry import psychoacoustics

# One line — the contour family straight from the result:
res = psychoacoustics.equal_loudness_contours()
res.plot()
plt.show()

# Or reproduce the figure by hand — ISO 226:2023 Formula (1) at the 29
# preferred frequencies of Table 1, one contour per loudness level:
fig, ax = plt.subplots()
for phon in [20, 40, 60, 80, 90]:
    freqs, spl = psychoacoustics.equal_loudness_contour(float(phon))
    ax.semilogx(freqs, spl, color="C0")
    ax.annotate(f"{phon} phon", xy=(1000, phon + 1), fontsize=9)
ft, tf = psychoacoustics.hearing_threshold()
ax.semilogx(ft, tf, "--", color="C1", label="Hearing threshold $T_f$")
ax.set(xlabel="Frequency [Hz]", ylabel="Sound pressure level [dB re 20 µPa]")
ax.grid(True, which="both", alpha=0.3)
ax.legend()
plt.show()
```

</details>

Validity per clause 4.1: 20-90 phon (80 phon above 4 kHz); the implementation
is verified against the Annex B tables in CI. Note this is the loudness of
*pure tones*; the loudness of arbitrary signals in sones is what the ISO 532
models on this page compute.

## Advanced loudness models

ISO 532-1 above is one of the **three** loudness model families phonometry
ships (four methods in the table below). This
section adds the **Moore-Glasberg** loudness of ISO 532-2/532-3 and the
**Sottek Hearing Model** loudness of ECMA-418-2:2025, whose shared auditory
front-end also powers the tonality and roughness metrics of
[Sound Quality Metrics](https://jmrplens.github.io/phonometry/guides/sound-quality/).

### Choosing a loudness model

| Model | Standard | Stationary / time-varying | Output | When to use |
| :--- | :--- | :--- | :--- | :--- |
| Zwicker | ISO 532-1:2017 | both | sone | Reference method; one-third-octave input; fast and widely cited |
| Moore-Glasberg | ISO 532-2:2017 | stationary | sone | roex excitation pattern; better for tones and explicit binaural summation |
| Moore-Glasberg-Schlittenlacher | ISO 532-3:2023 | time-varying | sone (STL/LTL) | Time-varying loudness with short-/long-term traces and the peak N_max |
| Sottek (Hearing Model) | ECMA-418-2:2025 | time-varying | sone_HMS | Shares one auditory front-end with the ECMA tonality and roughness metrics |

All four methods are anchored so a **1 kHz tone at 40 dB SPL is ≈ 1 sone**; the values
are not interchangeable digit-for-digit because the models differ in their
auditory filters and their loudness summation.

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/loudness_models_comparison_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/loudness_models_comparison.svg" alt="Loudness of a 1 kHz tone as a function of level for the Zwicker, Moore-Glasberg and Sottek models, all passing through 1 sone at 40 dB SPL" width="80%"></picture>

*The three models agree at the 1 sone / 40 dB anchor and diverge with level:
Zwicker doubles the sone value every +10 phon, while the Sottek model grows
more slowly (about 1.65× per 10 dB), an intrinsic difference between the
auditory summations, not a calibration error.*

<details>
<summary>Show the code for this figure</summary>

```python
import matplotlib.pyplot as plt
import numpy as np
from phonometry import psychoacoustics

# 1 kHz tone, 20..80 dB SPL: all three models pass through 1 sone at 40 dB
fs = 48000
t = np.arange(fs) / fs
levels = np.arange(20.0, 81.0, 10.0)
zw, mg, ec = [], [], []
for spl in levels:
    x = np.sqrt(2) * 2e-5 * 10 ** (spl / 20) * np.sin(2 * np.pi * 1000 * t)
    zw.append(psychoacoustics.loudness_zwicker(x, fs, stationary=True).loudness)
    mg.append(
        psychoacoustics.loudness_moore_glasberg_from_spectrum([(1000.0, float(spl))]).loudness
    )
    ec.append(psychoacoustics.loudness_ecma(x, fs).loudness)

fig, ax = plt.subplots()
ax.plot(levels, zw, "o-", label="Zwicker (ISO 532-1)")
ax.plot(levels, mg, "s--", label="Moore-Glasberg (ISO 532-2)")
ax.plot(levels, ec, "^-.", label="Sottek (ECMA-418-2)")
ax.plot(40.0, 1.0, "o", color="k", markerfacecolor="none", markersize=10)   # the shared anchor
ax.set(xlabel="Sound pressure level [dB SPL]", ylabel="Total loudness N [sone]")
ax.legend()
plt.show()
```

</details>

### Moore-Glasberg loudness (ISO 532-2)

Where Zwicker uses fixed critical bands on the Bark scale, Moore-Glasberg builds
an **excitation pattern** with level-dependent rounded-exponential (roex)
auditory filters on the ERB-number ("Cam") scale, then applies a compressive
excitation → specific-loudness transform with C = 0.0617 sone/Cam
(ISO 532-2:2017, Formula 7) and a binaural-inhibition stage. It reproduces the
tone and broadband cases of Annex B to a percent or two and, unlike ISO 532-1,
models binaural summation explicitly.

```python
import numpy as np
from phonometry import psychoacoustics

# The definitional anchor: one 1 kHz sinusoidal component at 40 dB SPL,
# free field, binaural -> 1 sone / 40 phon by construction of the sone.
res = psychoacoustics.loudness_moore_glasberg_from_spectrum([(1000.0, 40.0)], field="free")
print(f"N = {res.loudness:.3f} sone  ({res.loudness_level:.1f} phon)")   # 1.000 sone (40.0 phon)

# From a calibrated recording: the narrowband (FFT) line spectrum is formed
# (power-preserving normalization) and fed to the exact sinusoidal-component
# method (ISO 532-2 clauses 5.2/5.4).
fs = 48000
x = np.sqrt(2) * 2e-5 * 10 ** (40 / 20) * np.sin(2 * np.pi * 1000 * np.arange(fs) / fs)
res = psychoacoustics.loudness_moore_glasberg(x, fs, field="free", presentation="binaural")

res.plot()   # specific loudness N'(i) over the ERB-number (Cam) scale
```

<details>
<summary>Show the code for this figure</summary>

```python
import matplotlib.pyplot as plt
import numpy as np
from phonometry import psychoacoustics

# From a calibrated recording: the narrowband (FFT) line spectrum is formed
# (power-preserving normalization) and fed to the exact sinusoidal-component
# method (ISO 532-2 clauses 5.2/5.4).
fs = 48000
x = np.sqrt(2) * 2e-5 * 10 ** (40 / 20) * np.sin(2 * np.pi * 1000 * np.arange(fs) / fs)
res = psychoacoustics.loudness_moore_glasberg(x, fs, field="free", presentation="binaural")

# One line — the specific-loudness pattern N'(i) straight from the result:
res.plot()
plt.show()

# Or draw it by hand from the ERB-number grid the result already carries:
fig, ax = plt.subplots()
ax.fill_between(res.erb_number, res.specific, alpha=0.3)
ax.plot(res.erb_number, res.specific)
ax.set_xlabel("ERB number [Cam]")
ax.set_ylabel("Specific loudness N' [sone/Cam]")
plt.show()
```

</details>

#### `loudness_moore_glasberg()` parameters

| Parameter | Type | Units | Range / default | Notes |
| :--- | :--- | :--- | :--- | :--- |
| `x` | 1D array | Pa | non-empty | Calibrated pressure signal (signal wrapper) |
| `components` | list of `(f, L)` | Hz, dB SPL | — | `_from_spectrum`: discrete sinusoidal components |
| `band_levels` | 29-vector | dB SPL | 25 Hz .. 16 kHz | `_from_third_octave` input (IEC 61260-1 bands) |
| `fs` | int | Hz | > 0 | Signal wrapper only |
| `field` | str | — | `'free'` (default) / `'diffuse'` / `'eardrum'` | Outer-ear transfer |
| `presentation` | str | — | `'binaural'` (default) / `'diotic'` / `'monaural'` | Binaural summation |

Returns a `MooreGlasbergLoudness`: `loudness` (N, sone), `loudness_level`
(phon), `specific` (N′(i), 372 bins of 0.1 Cam), `erb_number`,
`centre_frequencies`, `field`, `presentation`.

### Time-varying loudness (ISO 532-3)

ISO 532-3 wraps the same excitation / specific-loudness model in a running
multi-resolution spectral analysis (six parallel FFTs, updated every 1 ms) and
two cascaded temporal integrators: the fast **short-term loudness** S′(t) and
the slower **long-term loudness** S″(t). The peak long-term loudness N_max
predicts the loudness of sounds up to about 5 s.

```python
import numpy as np
from phonometry import psychoacoustics

fs = 32000
t = np.arange(int(1.3 * fs)) / fs
x = np.sqrt(2) * 2e-5 * 10 ** (40 / 20) * np.sin(2 * np.pi * 1000 * t)

res = psychoacoustics.loudness_moore_glasberg_time(x, fs, field="free")
print(f"N_max = {res.n_max:.3f} sone  ({res.loudness_level_max:.0f} phon)")   # 1.000 sone (40 phon)
print(f"long-term loudness exceeded 5% of the time: {res.percentiles[5.0]:.3f} sone")   # 0.999 sone

res.plot()   # short-term S'(t) and long-term S''(t) loudness vs time
```

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/moore_glasberg_time_loudness_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/moore_glasberg_time_loudness.svg" alt="Short-term and long-term Moore-Glasberg loudness traces for a tone burst, showing the fast attack of the short-term loudness and the slower release of the long-term loudness" width="80%"></picture>

<details>
<summary>Show the code for this figure</summary>

```python
import matplotlib.pyplot as plt
import numpy as np
from phonometry import psychoacoustics

fs = 32000
t = np.arange(int(1.3 * fs)) / fs
x = np.sqrt(2) * 2e-5 * 10 ** (40 / 20) * np.sin(2 * np.pi * 1000 * t)
res = psychoacoustics.loudness_moore_glasberg_time(x, fs, field="free")

# The result carries both traces on a 1 ms time axis:
res.plot()
plt.show()

# Or plot them directly to see the fast STL vs the slow LTL:
fig, ax = plt.subplots()
ax.plot(res.time, res.short_term_loudness, label="Short-term S'(t)")
ax.plot(res.time, res.long_term_loudness, label="Long-term S''(t)")
ax.set_xlabel("Time [s]")
ax.set_ylabel("Loudness [sone]")
ax.legend()
plt.show()
```

</details>

#### `loudness_moore_glasberg_time()` parameters

| Parameter | Type | Units | Range / default | Notes |
| :--- | :--- | :--- | :--- | :--- |
| `signal` | 1D or `(n, 2)` array | Pa | non-empty | Mono = diotic; two columns = left/right ears |
| `fs` | int | Hz | > 0 | |
| `field` | str | — | `'free'` (default) / `'diffuse'` / `'eardrum'` | Outer-ear transfer |
| `presentation` | str | — | `'binaural'` (default) / `'diotic'` / `'monaural'` | Binaural summation |
| `percentiles` | sequence | percent | default `(1, 5, 10, 50, 90, 95)` | Exceeded long-term loudness levels |

Returns a `MooreGlasbergTimeVaryingLoudness`: `time` (1 ms grid),
`short_term_loudness` / `long_term_loudness` (sone), their `_level` in phon,
`n_max`, `loudness_level_max`, a `percentiles` dict, `field`, `presentation`.

### Sottek Hearing Model loudness (ECMA-418-2)

ECMA-418-2:2025 specifies a single auditory front-end (outer/middle-ear
filtering, a 53-band gammatone-like filter bank on the Bark_HMS scale
with z = 0.5 .. 26.5, half-wave rectification, block RMS and a compressive
nonlinearity, Formula 23) that is **shared** by its loudness, tonality and
roughness metrics. The loudness N is reported in **sone_HMS**, and the same
1 kHz/40 dB anchor calibrates the front-end (our clean-room value 0.984,
with the full Clause 6.2.3 band averaging; the residual's origin is
documented in the module docstring).

```python
import numpy as np
from phonometry import psychoacoustics

fs = 48000
t = np.arange(int(1.2 * fs)) / fs
x = np.sqrt(2) * 2e-5 * 10 ** (40 / 20) * np.sin(2 * np.pi * 1000 * t)

res = psychoacoustics.loudness_ecma(x, fs, field="free")
print(f"N = {res.loudness:.3f} sone_HMS")   # 0.984 sone_HMS
print(res.specific_loudness.shape)          # (53,) average specific loudness N'(z)

res.plot()   # average specific loudness N'(z) + time-dependent N(l) at 187.5 Hz
```

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/sottek_specific_loudness_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/sottek_specific_loudness.svg" alt="Sottek Hearing Model average specific loudness N'(z) over the 53 Bark_HMS bands for a 1 kHz tone, peaking at the tone's critical band" width="80%"></picture>

<details>
<summary>Show the code for this figure</summary>

```python
import matplotlib.pyplot as plt
import numpy as np
from phonometry import psychoacoustics

fs = 48000
t = np.arange(int(1.2 * fs)) / fs
x = np.sqrt(2) * 2e-5 * 10 ** (40 / 20) * np.sin(2 * np.pi * 1000 * t)
res = psychoacoustics.loudness_ecma(x, fs, field="free")

# The result carries the average specific loudness over the 53 Bark_HMS bands:
res.plot()
plt.show()

# Or draw N'(z) by hand against the critical-band-rate scale:
fig, ax = plt.subplots()
ax.fill_between(res.bark, res.specific_loudness, alpha=0.3)
ax.plot(res.bark, res.specific_loudness)
ax.set_xlabel("Critical-band rate z [Bark_HMS]")
ax.set_ylabel("Specific loudness N' [sone_HMS/Bark_HMS]")
plt.show()
```

</details>

#### `loudness_ecma()` parameters

| Parameter | Type | Units | Range / default | Notes |
| :--- | :--- | :--- | :--- | :--- |
| `signal_in` | 1D array | Pa | non-empty | Calibrated pressure signal |
| `fs` | float | Hz | > 0 | Resampled to 48 kHz internally if needed (Clause 5.1.1) |
| `field` | str | — | `'free'` (default) / `'diffuse'` | Outer/middle-ear filter (Clause 5.1.3) |

Returns an `EcmaLoudness`: `loudness` (N, sone_HMS), `specific_loudness`
(N′(z), 53 bands), `bark`, `centre_frequencies`, `time`, `loudness_vs_time`
(N(l) at 187.5 Hz), `field`.

## References

- Fastl, H., & Zwicker, E. (2007). *Psychoacoustics: Facts and models*
  (3rd ed.). Springer.
  [doi:10.1007/978-3-540-68888-4](https://doi.org/10.1007/978-3-540-68888-4).
  The critical-band and masking psychoacoustics behind the Zwicker model and
  the loudness sensation the newer models refine.
- Fletcher, H., & Munson, W. A. (1933). Loudness, its definition, measurement
  and calculation. *The Journal of the Acoustical Society of America*, 5(2),
  82-108. [doi:10.1121/1.1915637](https://doi.org/10.1121/1.1915637).
  The original equal-loudness measurements behind the loudness-level concept
  of the pure-tone section.
- International Organization for Standardization. (2023). *Acoustics —
  Normal equal-loudness-level contours* (ISO 226:2023).
  [iso.org catalogue](https://www.iso.org/standard/83117.html).
  The contour model and Table 1 parameters behind the pure-tone loudness
  levels.

## Standards

ISO 532-1:2017, *Acoustics — Methods for calculating
loudness — Part 1: Zwicker method* — stationary and time-varying loudness in
sones from the normative Annex A.4 reference program, with the N5/N10
percentile loudness, validated against the Annex B set. ISO 532-2:2017,
*... Part 2: Moore-Glasberg method*: stationary loudness from roex excitation
patterns on the ERB-number scale, with explicit binaural summation.
ISO 532-3:2023, *... Part 3: Moore-Glasberg-Schlittenlacher method*:
time-varying short-term and long-term loudness and the peak N_max.
ISO 226:2023, *Acoustics — Normal equal-loudness-level contours* — the
contours (Formula 1), the loudness level of pure tones (Formula 2) and the
hearing threshold. ECMA-418-2:2025, *Psychoacoustic metrics for ITT
equipment — Part 2 (methods for describing human perception based on the
Sottek Hearing Model)*: the Sottek Hearing Model loudness (sone_HMS).

## See also

- [Sound Quality Metrics](https://jmrplens.github.io/phonometry/guides/sound-quality/): sharpness,
  tonality and roughness, the other half of the sound-quality story.
- [Psychoacoustic annoyance and fluctuation strength](psychoacoustic-annoyance.md):
  the Zwicker and Fastl model that consumes the percentile loudness N5.
- [Theory](https://jmrplens.github.io/phonometry/reference/theory/perception/): the equations behind the loudness models.
- API reference: [`psychoacoustics.loudness_zwicker`](https://jmrplens.github.io/phonometry/reference/api/psychoacoustics/loudness-zwicker/), [`psychoacoustics.loudness_moore_glasberg`](https://jmrplens.github.io/phonometry/reference/api/psychoacoustics/loudness-moore-glasberg/) and [`psychoacoustics.loudness_contours`](https://jmrplens.github.io/phonometry/reference/api/psychoacoustics/loudness-contours/).

---


<!-- source: docs/sound-quality.md | canonical: https://jmrplens.github.io/phonometry/guides/sound-quality/ -->

# Sound Quality Metrics

Two sounds of equal loudness can still differ in how *sharp*, how *tonal*,
how *rough* or how strongly *fluctuating* they are. This page covers the
sound-quality metrics that complement loudness: sharpness (DIN 45692) and the
ECMA-418-2 tonality, roughness and fluctuation strength of the Sottek Hearing
Model. Loudness itself, including the ECMA-418-2 loudness that shares the
same auditory front-end, lives in [Loudness](https://jmrplens.github.io/phonometry/guides/loudness/).

## Sharpness in acum (DIN 45692)

Two sounds can be equally loud yet one feels "sharper" (hissy, metallic)
because its loudness sits higher on the Bark scale. Sharpness is the
g(z)-weighted first moment of the specific loudness pattern:

$$
S = k\ \frac{\int_0^{24} N'(z)\ g(z)\ z\ dz}{\int_0^{24} N'(z)\ dz}\ \text{acum}
$$

with $g(z) = 1$ up to 15.8 Bark and rising exponentially beyond, and $k$
normalized so the reference sound (critical-band-wide noise at 1 kHz,
60 dB) is exactly **1.00 acum** (DIN 45692 clause 6; the derived
$k = 0.108$ sits inside the normative window 0.105–0.115).

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/sharpness_weighting_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/sharpness_weighting.svg" alt="DIN 45692 sharpness weighting g(z) against critical-band rate on a log axis, comparing the DIN, von Bismarck and Aures curves with the 15.8 and 15 Bark knees marked" width="80%"></picture>

<details>
<summary>Show the code for this figure</summary>

```python
import matplotlib.pyplot as plt
import numpy as np

# DIN 45692 sharpness weighting g(z): Eq. (1) plus the informative Annex B variants
z = np.arange(1, 241) * 0.1                     # Bark bins, 0.1 .. 24.0
g_din = np.where(z > 15.8, 0.15 * np.exp(0.42 * (z - 15.8)) + 0.85, 1.0)
g_bis = np.where(z > 15.0, 0.2 * np.exp(0.308 * (z - 15.0)) + 0.8, 1.0)
n = 4.0                                         # Aures depends on the total loudness (sone)
g_aures = 0.078 * np.exp(0.171 * z) / z * (n / np.log(n * 0.05 + 1.0))

fig, ax = plt.subplots()
ax.semilogy(z, g_din, label="DIN 45692 g(z)")
ax.semilogy(z, g_bis, "--", label="von Bismarck (Annex B)")
ax.semilogy(z, g_aures, "-.", label="Aures (Annex B, N = 4 sone)")
ax.axvline(15.8, linestyle=":", color="0.5")    # DIN knee: g rises beyond 15.8 Bark
ax.set(xlabel="Critical-band rate z [Bark]", ylabel="Weighting g(z)")
ax.grid(True, which="both", alpha=0.3)
ax.legend()
plt.show()
```

</details>

```python
import numpy as np
from phonometry import psychoacoustics

# A raw recording plus its calibration so the guide runs standalone
fs = 48000
x = 0.2 * np.sin(2 * np.pi * 1000 * np.arange(fs) / fs)   # any recording (digital units)
sens = 1.0                                                # calibration_factor to pascals

s = psychoacoustics.sharpness_din(x, fs, calibration_factor=sens)      # acum
s_aures = psychoacoustics.sharpness_din(x, fs, method="aures")          # Annex B variant
```

CI verifies the Table A.2 target values (0.38 acum at 250 Hz up to
2.82 acum at 4 kHz) within the standard's 5 % / 0.05 acum tolerance.

## Tonality (ECMA-418-2)

A tonal component (a whistle, a fan's blade-passing tone) stands out even at
low level. ECMA-418-2 quantifies it from the **autocorrelation function** (ACF)
of each band's rectified signal: a periodic (tonal) component keeps a high ACF
at nonzero lag, and the tonal-to-noise loudness ratio drives the specific
tonality T′(z). The single value T is in **tu_HMS**, calibrated so a 1 kHz/40 dB
tone is ≈ 1 tu_HMS; the result also tracks the tonal frequency f_ton per band.

```python
import numpy as np
from phonometry import psychoacoustics

fs = 48000
t = np.arange(int(1.2 * fs)) / fs
x = np.sqrt(2) * 2e-5 * 10 ** (40 / 20) * np.sin(2 * np.pi * 1000 * t)

res = psychoacoustics.tonality_ecma(x, fs, field="free")
peak = int(np.argmax(res.specific_tonality))
print(f"T = {res.tonality:.3f} tu_HMS")                    # 1.000 tu_HMS
print(f"f_ton = {res.tonal_frequencies[peak]:.0f} Hz")     # 999 Hz

res.plot()   # average specific tonality T'(z) + time-dependent T(l)
```

### `tonality_ecma()` parameters

| Parameter | Type | Units | Range / default | Notes |
| :--- | :--- | :--- | :--- | :--- |
| `signal_in` | 1D array | Pa | non-empty | Calibrated pressure signal |
| `fs` | float | Hz | > 0 | Resampled to 48 kHz internally if needed |
| `field` | str | — | `'free'` (default) / `'diffuse'` | Outer/middle-ear filter |
| `f_low` | float, optional | Hz | default `None` | Lower edge of a user band for the T(l) search |
| `f_high` | float, optional | Hz | default `None` | Upper edge of the user band |

Returns an `EcmaTonality`: `tonality` (T, tu_HMS), `specific_tonality`
(T′(z), 53 bands), `bark`, `centre_frequencies`, `tonal_frequencies`
(f_ton,z), `time`, `tonality_vs_time` (T(l)), `tonal_frequency_vs_time`,
`field`.

## Roughness (ECMA-418-2): new capability

Roughness is the harsh, buzzing sensation of fast amplitude modulation
(roughly 20–300 Hz, peaking near 70 Hz): the quality of a diesel idle or a
distorted loudspeaker. It is a **new metric** for phonometry. ECMA-418-2
extracts each band's envelope, weights its modulation spectrum by modulation
rate and depth, and correlates the modulation across bands; the result R is in
**asper**. The reference sound (1 kHz carrier, 100 % amplitude-modulated at
70 Hz, overall level 60 dB SPL) is defined as 1 asper; this clean-room
implementation returns 0.9999 asper with the tabulated calibration constant
c_R (Formula 104) used **without** reverse-fitting to the target.

```python
import numpy as np
from phonometry import psychoacoustics

fs = 48000
t = np.arange(int(2.0 * fs)) / fs
x = (1.0 + np.cos(2 * np.pi * 70 * t)) * np.sin(2 * np.pi * 1000 * t)
x *= 2e-5 * 10 ** (60 / 20) / np.sqrt(np.mean(x**2))   # overall 60 dB SPL

res = psychoacoustics.roughness_ecma(x, fs, field="free")
print(f"R = {res.roughness:.4f} asper")   # 0.9999 asper (reference: 1 asper)

res.plot()   # time-dependent roughness R(l50) + specific-roughness heatmap
```

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/tonality_roughness_demo_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/tonality_roughness_demo.svg" alt="ECMA-418-2 sound-quality demo: a tonal sound scores high tonality and near-zero roughness, while a 70 Hz amplitude-modulated sound scores high roughness and low tonality" width="80%"></picture>

<details>
<summary>Show the code for this figure</summary>

```python
import matplotlib.pyplot as plt
import numpy as np
from phonometry import psychoacoustics

fs = 48000
t = np.arange(int(2.0 * fs)) / fs
amp = np.sqrt(2) * 2e-5 * 10 ** (60 / 20)

# A pure tone (tonal, smooth) vs a 70 Hz amplitude-modulated tone (rough),
# both normalized to an overall level of 60 dB SPL:
tone = amp * np.sin(2 * np.pi * 1000 * t)
rough = (1.0 + np.cos(2 * np.pi * 70 * t)) * np.sin(2 * np.pi * 1000 * t)
rough *= 2e-5 * 10 ** (60 / 20) / np.sqrt(np.mean(rough**2))

scores = {
    "Pure tone": (psychoacoustics.tonality_ecma(tone, fs).tonality, psychoacoustics.roughness_ecma(tone, fs).roughness),
    "70 Hz AM tone": (psychoacoustics.tonality_ecma(rough, fs).tonality, psychoacoustics.roughness_ecma(rough, fs).roughness),
}
labels = list(scores)
tonal = [scores[k][0] for k in labels]
rough_v = [scores[k][1] for k in labels]
xpos = np.arange(len(labels))
fig, ax = plt.subplots()
ax.bar(xpos - 0.2, tonal, 0.4, label="Tonality [tu_HMS]")
ax.bar(xpos + 0.2, rough_v, 0.4, label="Roughness [asper]")
ax.set_xticks(xpos)
ax.set_xticklabels(labels)
ax.legend()
plt.show()
```

</details>

### `roughness_ecma()` parameters

| Parameter | Type | Units | Range / default | Notes |
| :--- | :--- | :--- | :--- | :--- |
| `signal_in` | 1D array | Pa | non-empty | Calibrated pressure signal |
| `fs` | float | Hz | > 0 | Resampled to 48 kHz internally if needed |
| `field` | str | — | `'free'` (default) / `'diffuse'` | Outer/middle-ear filter |

Returns an `EcmaRoughness`: `roughness` (R, asper, the 90th percentile of
R(l50)), `specific_roughness` (R′(z), 53 bands), `bark`, `centre_frequencies`,
`time`, `roughness_vs_time` (R(l50)), `specific_roughness_vs_time`
((n_times, 53) array), `field`.

## Fluctuation strength (ECMA-418-2): new capability

Fluctuation strength is the slow, wobbling sensation of amplitude or
frequency modulation below about 20 Hz: a siren, beating tones, speech at
syllable rate. It is the slow counterpart of roughness: the same hearing
model splits envelope modulation into a slow band-pass peaking near 4 Hz
(fluctuation strength, in **vacil_HMS**) and a fast one peaking near 70 Hz
(roughness). ECMA-418-2 Clause 9 analyses each band's envelope with
High-resolution Spectral Analysis (HSA), a least-squares fit of
window-kernel spectral line pairs that resolves modulation rates far below
the DFT bin width, using envelope-dependent analysis windows that skip
quieter periods, then weights the dominant harmonic complex and scales it
with an HSA-based specific loudness. The reference sound (1 kHz carrier,
100 % amplitude-modulated at 4 Hz, overall level 60 dB SPL) is defined as
1 vacil_HMS; this clean-room implementation converges to 0.9958 vacil_HMS
by 12 s with the tabulated calibration constant c_F (Formula 163) used
**without** reverse-fitting to the target (the 8 s example below prints
0.9957). A signal whose single value F exceeds 0.2 vacil_HMS has a
*prominent* fluctuation strength (Clause 9.2).

```python
import numpy as np
from phonometry import psychoacoustics

fs = 48000
t = np.arange(int(8.0 * fs)) / fs
x = (1.0 + np.cos(2 * np.pi * 4 * t)) * np.sin(2 * np.pi * 1000 * t)
x *= 2e-5 * 10 ** (60 / 20) / np.sqrt(np.mean(x**2))   # overall 60 dB SPL

res = psychoacoustics.fluctuation_strength_ecma(x, fs, field="free")
print(f"F = {res.fluctuation_strength:.4f} vacil_HMS")   # 0.9957 vacil_HMS (reference: 1 vacil_HMS)

res.plot()   # time-dependent F(l50) + specific-fluctuation-strength heatmap
```

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/hms_modulation_bandpass_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/hms_modulation_bandpass.svg" alt="ECMA-418-2 slow vs fast modulation perception: fluctuation strength forms a band-pass over modulation frequency peaking near 4 to 6 Hz while roughness of the same 1 kHz amplitude-modulated tones peaks near 70 Hz" width="80%"></picture>

<details>
<summary>Show the code for this figure</summary>

```python
import matplotlib.pyplot as plt
import numpy as np
from phonometry import psychoacoustics

fs = 48000
t = np.arange(int(3.0 * fs)) / fs
carrier = np.sin(2 * np.pi * 1000 * t)

def am_tone(fmod):
    # 100 % AM at an overall level of 60 dB SPL (the Clause 7/9 convention)
    x = (1.0 + np.sin(2 * np.pi * fmod * t)) * carrier
    return x * 2e-5 * 10 ** (60 / 20) / np.sqrt(np.mean(x**2))

fm_slow = [0.5, 1, 2, 4, 8, 16, 32]
fm_fast = [20, 40, 70, 100, 150, 200]
f_vals = [psychoacoustics.fluctuation_strength_ecma(am_tone(fm), fs).fluctuation_strength
          for fm in fm_slow]
r_vals = [psychoacoustics.roughness_ecma(am_tone(fm), fs).roughness for fm in fm_fast]

fig, ax = plt.subplots()
ax.semilogx(fm_slow, f_vals, "o-", label="Fluctuation strength F [vacil_HMS]")
ax.semilogx(fm_fast, r_vals, "s-", label="Roughness R [asper]")
ax.set(xlabel="Modulation frequency [Hz]", ylabel="F [vacil_HMS] / R [asper]")
ax.legend()
plt.show()
```

</details>

### `fluctuation_strength_ecma()` parameters

| Parameter | Type | Units | Range / default | Notes |
| :--- | :--- | :--- | :--- | :--- |
| `signal_in` | 1D array | Pa | non-empty | Calibrated pressure signal |
| `fs` | float | Hz | > 0 | Resampled to 48 kHz internally if needed |
| `field` | str | — | `'free'` (default) / `'diffuse'` | Outer/middle-ear filter |

Returns an `EcmaFluctuationStrength`: `fluctuation_strength` (F, vacil_HMS, the
90th percentile of F(l50)), `specific_fluctuation_strength` (F′(z), 53
bands), `bark`, `centre_frequencies`, `time`, `fluctuation_strength_vs_time`
(F(l50)), `specific_fluctuation_strength_vs_time` ((n_times, 53) array),
`field`.

The Fastl & Zwicker fluctuation-strength models (closed form for AM
broadband noise and the Osses 2016 signal model) live in
[Psychoacoustic Annoyance](psychoacoustic-annoyance.md); this Clause 9
metric is the normative Sottek-model counterpart.

See [Prominent Discrete Tones](https://jmrplens.github.io/phonometry/guides/tone-prominence/) for the ECMA-418-1 TNR/PR
prominence verdicts, [Speech Transmission Index](https://jmrplens.github.io/phonometry/guides/speech-transmission/) for
STI/STIPA, and [Theory](https://jmrplens.github.io/phonometry/reference/theory/perception/) for the underlying math.

## References

- Fastl, H., & Zwicker, E. (2007). *Psychoacoustics: Facts and models*
  (3rd ed.). Springer.
  [doi:10.1007/978-3-540-68888-4](https://doi.org/10.1007/978-3-540-68888-4).
  The psychoacoustics of the sensations quantified on this page: the
  high-frequency emphasis behind sharpness and the fast-modulation percept
  behind roughness.

## Standards

DIN 45692:2009, *Messtechnische Simulation der Hörempfindung
Schärfe*: sharpness in acum (clause 6 weighting, Annex B von Bismarck and
Aures variants, Table A.2 targets). ECMA-418-2:2025, *Psychoacoustic metrics
for ITT equipment — Part 2 (methods for describing human perception based on
the Sottek Hearing Model)*: the Sottek Hearing Model tonality (tu_HMS,
clause 6), roughness (asper, clause 7) and fluctuation strength (vacil_HMS,
clause 9, the HSA-based envelope analysis).

---


<!-- source: docs/speech-transmission.md | canonical: https://jmrplens.github.io/phonometry/guides/speech-transmission/ -->

# Speech Transmission Index (IEC 60268-16)

A public-address system, an intercom, a reverberant lecture hall: each is a
*transmission channel* between a talker's mouth and a listener's ear, and each
degrades speech in its own way. The **Speech Transmission Index** (STI) of
IEC 60268-16 rates that channel with a single number in [0, 1] by measuring
how much of the speech *envelope* survives the trip. This page covers the
modulation-transfer physics behind the index, the indirect method from a
measured room impulse response, and the direct STIPA measurement with its
standardized test signal.

> [!NOTE]
> **STI vs SII.** The STI characterises a *transmission channel* (how much of
> the speech modulation a room or sound system preserves) while the SII
> predicts intelligibility from *audibility*: how much of the speech spectrum
> clears the noise and the hearing threshold at the listener's ear. For the
> latter, see the [Speech Intelligibility Index guide](https://jmrplens.github.io/phonometry/guides/speech-intelligibility/).

## 1. The modulation transfer function

Reverberation and noise do not muffle speech uniformly; they blur its
*envelope*: the slow (0.63–12.5 Hz) intensity modulations that carry
syllables. STI quantifies how much of that modulation survives from mouth
to ear, per octave band, as the **modulation transfer function** m(F). A
delta-like channel keeps m = 1 (STI = 1); reverberation low-passes the
envelope following Schroeder's closed form, and steady noise scales it:

$$
m(F) = \frac{1}{\sqrt{1 + \left(2\pi F\,\frac{T_{60}}{13.8}\right)^2}}
\cdot \frac{1}{1 + 10^{-\mathrm{SNR}/10}}
$$

Modulation *depth* is the thing worth measuring because intelligibility rides
on the depth of the envelope valleys, not on the loudness of the peaks. A
talker alternates energy bursts (vowels) with near-silences (stop gaps,
fricative onsets) at syllable rate, and a listener segments speech by hearing
those dips. A reverberant tail fills the dips from behind, since late energy
smears into the gaps; steady noise raises their floor. In both cases the
received modulation depth shrinks, and with it the contrast between speech
sounds, even when the average level barely changes. The full method probes
m(F) at 14 modulation frequencies (0.63 Hz to 12.5 Hz in one-third-octave
steps) in each of the 7 octave bands from 125 Hz to 8 kHz, converts each m to
an effective signal-to-noise ratio clipped to ±15 dB, and combines the
results, band-weighted, into the index: the STI is an effective SNR of the
*envelope*, mapped onto [0, 1].

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/sti_vs_t60_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/sti_vs_t60.svg" alt="STI versus reverberation time with the IEC 60268-16 Annex F rating bands shaded" width="80%"></picture>

<details>
<summary>Show the code for this figure</summary>

```python
import numpy as np
import matplotlib.pyplot as plt
from phonometry import hearing

fs = 48000

# STI vs reverberation time: sweep hearing.sti_from_impulse_response over synthetic
# exponential decays (white noise x exp(-6.9077 t / T60)) at a T60 grid,
# exactly the physics behind the curve above:
rng = np.random.default_rng(0)
t60_grid = np.array([0.3, 0.5, 0.8, 1.2, 1.6, 2.0, 2.5, 3.0, 4.0, 5.0])
sti_values = []
for t60 in t60_grid:
    t = np.arange(int(2 * t60 * fs)) / fs
    ir = rng.standard_normal(t.size) * np.exp(-6.9077 * t / t60)
    sti_values.append(hearing.sti_from_impulse_response(ir, fs).sti)

fig, ax = plt.subplots()
ax.semilogx(t60_grid, sti_values, "o-")
ax.set_xlabel("Reverberation time T60 [s]")
ax.set_ylabel("STI")
ax.set_ylim(0.0, 1.0)
ax.grid(True, which="both", alpha=0.3)
plt.show()
```

</details>

## 2. Indirect and direct (STIPA) measurement

```python
import numpy as np
from phonometry import hearing

fs = 48000
# A measured room impulse response (synthesized decay so the example runs)
ir = np.random.default_rng(0).standard_normal(fs) * np.exp(-6.9 * np.arange(fs) / fs / 0.5)

# Indirect method: from a measured room impulse response
res = hearing.sti_from_impulse_response(ir, fs, snr=25.0)
print(f"STI = {res.sti:.2f}  ({res.rating})")   # e.g. 0.62 (D)

# Direct STIPA measurement: play hearing.stipa_signal() in the room, record it
test = hearing.stipa_signal(fs, seconds=18.0, level_db=80.0)
recording = test                       # in practice, the microphone signal after playback
res = hearing.stipa(recording, fs)
res.plot()   # per-band modulation transfer index (MTI) bars, STI + rating in the title
```

The direct measurement sends the STIPA signal along the full chain drawn below,
from the source through the room to the microphone and into the per-band
modulation analysis that yields the index.

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/diagram_sti_chain_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/diagram_sti_chain.svg" alt="STI measurement chain: STIPA source signal through the room to the microphone and the MTF analysis" width="92%"></picture>

`stipa` emits a `UserWarning` when the recording is shorter than the
recommended 15 s (IEC 60268-16 STIPA practice, 15 s to 25 s): below that the
slow modulation components are averaged over too few periods and the STI is
biased low (an ideal loopback gives STI ≈ 0.956 at 5 s vs ≈ 0.998 at 18 s).

The implementation follows **Edition 5 (2020)**: Edition 4's normative PDF
is the base and every Ed. 5 change is source-attributed in the code; the
only numeric delta is the revised male speech spectrum of clause A.6.1.
CI checks the standard's own verification vectors: the six weighting-factor
band pairs to ±0.001 STI, the m ↔ STI mapping table, the level-dependent
masking control points, and Schroeder-form decays at four T₆₀ values.

The analyzer is also verified end to end against the **IEC 60268-16 rev 5
verification test bench** signals from [stipa.info](https://www.stipa.info)
(Embedded Acoustics BV): the direct-method modulation-depth staircase
(Annex C.3.2), the indirect-method exponential decays against the closed-form
Schroeder MTF (C.3.3), the filter-bank slope test with a +41 dB unmodulated
adjacent-octave tone (C.4.2, m ≥ 0.5), the weighting-factor band pairs (A.2.2)
and the filter-bank phase-distortion test with half-octave edge carriers
(A.3.1.2, |STI bias| < 0.01 over TI = 0.1–0.9). All five suites pass with the
level-dependent features disabled, as the bench prescribes. The 49 certified
WAVs stay local (third-party data, not committed); CI re-derives the same
signal constructions synthetically in the conformance suite.

### Direct or indirect: choosing between them

Each route has failure modes the standard is explicit about:

- **Non-linear or time-variant channels.** The indirect method assumes a
  linear, time-invariant channel: an impulse response cannot represent
  clipping, compressors, automatic gain control or a vocoder. For a sound
  system with non-linear processing in the chain, measure directly: the STIPA
  signal at least travels through the real chain, and the FULL STI signal is
  the reliable choice where the distortion is severe (IEC 60268-16 clause 6.3
  and Table 3).
- **Level-dependent effects.** The STI is not level-invariant: auditory
  masking and the reception threshold act on the *absolute* band levels at the
  listener. Play the test signal at the system's operating level (the
  standard's Annex J practice sets it 3 dB above the L_Aeq of continuous
  speech at the position) and pass `level=` and `ambient=` so the analysis
  includes them; an impulse response measured loud and rescaled afterwards
  misses these effects entirely.
- **Impulsive and fluctuating background noise.** A dropped tool or babble
  during a direct measurement corrupts the measured modulation depths
  (clause 7.13). The standard's remedy is the indirect route: average the
  impulse response with MLS or sweeps for a noise-free MTF, then add the noise
  degradation back via `snr=` or `level=`/`ambient=`. A quick sanity check is
  to run the analyzer with the source off; the residual STI should stay below
  0.20.
- **Statistical spread.** The STIPA signal is pseudo-random noise, so repeated
  direct measurements scatter by up to about 0.03 STI even in steady
  conditions (and more in fluctuating noise); repeat and compare rather than
  trusting a single run, and respect the minimum duration flagged by the
  `UserWarning` above.

### `sti_from_impulse_response()` / `stipa()` parameters

| Parameter | Type | Units | Range / default | Notes |
| :--- | :--- | :--- | :--- | :--- |
| `ir` / `x` | 1D array | any / Pa | non-empty | IR (indirect) or STIPA recording (direct) |
| `fs` | int | Hz | > 0 | |
| `snr` | float or 7-vector, optional | dB | default `None` | Adds steady-noise degradation |
| `level` | 7-vector, optional | dB SPL | default `None` | Enables auditory masking + reception threshold (Tables A.2/A.3) |
| `ambient` | 7-vector, optional | dB SPL | needs `level` | Ambient noise band levels |
| `reference` | 1D array, optional (`stipa`) | — | default `None` | Measured source signal instead of the nominal m = 0.55 |

Both return `STIResult`: `sti`, `mti` (7 bands), `mtf` (7×14 or 7×2),
`band_levels`, `rating` (Annex F letter `A+`…`U`).

### IEC 60268-16 report (`.report()`)

`STIResult.report(path)` renders a one-page PDF fiche laid out like a
voice-alarm / public-address intelligibility verification report: a
standard-basis line stating the measurement method (the full STI indirect
method from an impulse response, or the direct STIPA method on a recorded
signal), an optional metadata header block, a per-octave-band modulation
transfer index table beside the per-band MTI bars (the result's own `.plot()`),
the boxed `STI = X` single number with the Annex F qualification band, an
optional verdict row and a footer with the fixed disclaimer. It uses the same
`ReportMetadata` container (documented under
[Field insulation](https://jmrplens.github.io/phonometry/guides/insulation-field/#report-metadata-reportmetadata)) and
rendering engine as the ISO 717 insulation fiche; a supplied `requirement` is
read as the minimum required STI (a higher STI passes). Rendering needs
reportlab (`pip install phonometry[report]`); only `engine="reportlab"` is
supported. Pass `language="es"` for a Spanish fiche.

```python
from phonometry import hearing, ReportMetadata

res = hearing.sti_from_impulse_response(ir, fs)
res.report(
    "sti_fiche.pdf",
    metadata=ReportMetadata(
        specimen="Concourse voice-alarm loudspeaker line",
        measurement_standard="IEC 60268-16",
        laboratory="Phonometry Reference Laboratory",
        requirement=0.5,             # minimum required STI (a higher STI passes)
    ),
)
```

The example fiche, regenerated with `make reports`, is kept rendered in the
repository. Click the preview to open the PDF:

[![IEC 60268-16 STI example report: a metadata header, an octave-band modulation transfer index table, the per-band MTI bars, the boxed STI = 0.64 single number with the Annex F qualification band and a PASS verdict against a 0.5 minimum](https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/reports/iec60268_16_sti_example.webp)](https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/reports/iec60268_16_sti_example.pdf)

*Speech transmission index fiche (`STIResult.report`), STI with the Annex F band.*

## See also

- [Room Acoustics](https://jmrplens.github.io/phonometry/guides/room-acoustics/): the measured impulse response the
  indirect method consumes, and the open-plan metrics (ISO 3382-3) built on
  per-position STI.
- [Speech Intelligibility Index](https://jmrplens.github.io/phonometry/guides/speech-intelligibility/): the
  audibility-based ANSI S3.5 index that complements the STI.
- [Loudness](https://jmrplens.github.io/phonometry/guides/loudness/) and [Sound Quality Metrics](https://jmrplens.github.io/phonometry/guides/sound-quality/): loudness,
  sharpness, tonality and roughness of the received sound.
- [Theory](https://jmrplens.github.io/phonometry/reference/theory/perception/): the modulation-transfer derivation and the m ↔ STI
  mapping.
- API reference: [`hearing.sti`](https://jmrplens.github.io/phonometry/reference/api/speech/sti/).

## References

- Houtgast, T., & Steeneken, H. J. M. (1985). A review of the MTF concept in
  room acoustics and its use for estimating speech intelligibility in
  auditoria. *The Journal of the Acoustical Society of America*, 77(3),
  1069-1077. [doi:10.1121/1.392224](https://doi.org/10.1121/1.392224).
  The modulation-transfer framework of section 1 and the m ↔ STI mapping the
  index is built on.

## Standards

IEC 60268-16:2020 (Edition 5), *Sound system equipment —
Part 16: Objective rating of speech intelligibility by speech transmission
index*: the modulation transfer function and the m ↔ STI mapping, the STIPA
test signal and direct method, the indirect method from the impulse response,
auditory masking and the reception threshold (Tables A.2/A.3), the revised
male speech spectrum (clause A.6.1) and the Annex F rating letters.

---


<!-- source: docs/speech-intelligibility.md | canonical: https://jmrplens.github.io/phonometry/guides/speech-intelligibility/ -->

# Speech Intelligibility Index (SII)

The **Speech Intelligibility Index** predicts how much of a speech signal is
audible, and therefore intelligible, to a listener in a given noise and hearing
condition. It reduces a speech spectrum, a noise spectrum and a hearing
threshold to a single number in `[0, 1]`: `0` when nothing useful reaches the
listener, `1` when the whole speech-bearing spectrum is audible. This page
covers the **one-third-octave-band method** of **ANSI S3.5-1997 (R2017)**: 18
bands from 160 Hz to 8000 Hz.

> [!NOTE]
> **SII vs STI.** The SII predicts intelligibility from *audibility* (how much
> of the speech spectrum clears the noise and the hearing threshold at the
> listener's ear) while the STI characterises a *transmission channel*: how
> much of the speech modulation a room or sound system preserves. For the
> latter, see the [Speech Transmission Index guide](https://jmrplens.github.io/phonometry/guides/speech-transmission/).

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/diagram_speech_intelligibility_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/diagram_speech_intelligibility.svg" alt="The SII computation flow: three equivalent-spectrum-level inputs (speech Ei', noise Ni', hearing threshold Ti') feed the self-speech masking and spread-of-masking stage (equivalent masking spectrum level Zi), then the equivalent disturbance Di, then the band-audibility function Ai clipped to [0, 1], and finally the band-importance-weighted sum SII = sum of Ii*Ai over the 18 one-third-octave bands" width="94%"></picture>

## 1. Inputs and the band-importance function

All three inputs are **equivalent spectrum levels** (ANSI S3.5-1997 clauses 3.11
and 3.55) sampled at the 18 one-third-octave band centres: the speech spectrum
level `Ei'`, the noise spectrum level `Ni'` (both in dB SPL) and the hearing
threshold `Ti'` (in dB HL). Each band `i` contributes to intelligibility in
proportion to its **band-importance function** `Ii` (ANSI S3.5-1997 Table 3,
average speech material), which sums to one across the 18 bands.

```python
from phonometry import hearing

# The standard normal-effort speech spectrum (Table 3) in quiet, normal hearing.
result = hearing.speech_intelligibility_index("normal")
print(round(result.sii, 3))          # 0.996  (nearly everything audible)
print(round(hearing.sii.BAND_IMPORTANCE.sum(), 6))   # 1.0
```

With no noise and a normal hearing threshold the standard speech spectrum is
almost fully audible, so the index is close to one; the small deficit is the
listener's own **self-speech masking**.

The importance function is where the perceptual knowledge of the standard
lives. It descends from the articulation experiments behind French and
Steinberg's articulation index: listeners scored nonsense syllables heard
through filters that removed one part of the spectrum at a time, and the drop
in score measures how much intelligibility each band carries. The outcome is
strikingly unequal, and unrelated to where the speech *energy* sits: the five
bands from 1250 Hz to 3150 Hz carry about 43 % of intelligibility (the place
and manner cues of consonants live there), while the five lowest bands, 160 Hz
to 400 Hz, carry about 11 % even though they hold nearly half of the speech
power.
`Ii` from Table 3 is the average-speech compromise; the standard's Annex B
tabulates alternative importance functions for specific test materials
(nonsense syllables, monosyllabic word lists, short passages), which shift
weight according to how much redundancy the material offers.

## 2. Masking and the band-audibility function

The procedure (ANSI S3.5-1997 clause 5) turns the inputs into a per-band
audibility. Speech masks itself downward from each band (`Vi = Ei' - 24`); the
larger of that and the external noise, `Bi`, spreads **upward** in frequency
with a level-dependent slope to give the equivalent masking spectrum level `Zi`
(clause 5.4):

$$
Z_i = 10\log_{10}\!\left(10^{0.1 N_i'} + \sum_{k<i}
      10^{0.1\left(B_k + 3.32\,C_k\,\log_{10}(0.89\,f_i/f_k)\right)}\right).
$$

The masking is combined with the equivalent internal noise
(`Xi' = Xi + Ti'`, the reference internal noise shifted by the hearing loss)
into the **equivalent disturbance** `Di` (clause 5.6), and the **band-audibility
function** is the speech-to-disturbance ratio scaled into `[0, 1]` (clause 5.8):

$$
A_i = \operatorname{clip}\!\left(\frac{E_i' - D_i + 15}{30},\; 0,\; 1\right).
$$

At speech levels well above normal effort a **level-distortion factor** of
clause 5.7 (unity for the standard spectra used on this page) reduces $A_i$
further; phonometry applies it automatically.

## 3. The index in noise

The Speech Intelligibility Index is the band-importance-weighted sum of the band
audibilities (ANSI S3.5-1997 clause 6):

$$
\text{SII} = \sum_{i} I_i\, A_i .
$$

```python
import numpy as np
from phonometry import hearing

speech = hearing.standard_speech_spectrum("normal")
# A descending broadband masking noise (an office/ventilation-like spectrum).
noise = np.array([38.0, 37.0, 36.0, 34.0, 32.0, 30.0, 28.0, 26.0, 24.0,
                  22.0, 20.0, 18.0, 16.0, 14.0, 12.0, 10.0, 8.0, 6.0])

result = hearing.speech_intelligibility_index(speech, noise)
print(round(result.sii, 2))                # 0.46
print(result.band_audibility.round(2))     # per-band Ai
```

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/speech_intelligibility_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/speech_intelligibility.svg" alt="Band audibility of the standard normal-effort speech spectrum in a descending broadband noise: the light bars are the per-band audibility Ai across the 18 one-third-octave bands from 160 Hz to 8000 Hz, the darker bars the importance-weighted contribution Ii*Ai (scaled), and the overall SII is 0.46" width="90%"></picture>

<details>
<summary>Show the code for this figure</summary>

```python
import numpy as np
import matplotlib.pyplot as plt
from phonometry import hearing

speech = hearing.standard_speech_spectrum("normal")
noise = np.array([38.0, 37.0, 36.0, 34.0, 32.0, 30.0, 28.0, 26.0, 24.0,
                  22.0, 20.0, 18.0, 16.0, 14.0, 12.0, 10.0, 8.0, 6.0])
result = hearing.speech_intelligibility_index(speech, noise)

# One line:
result.plot()
plt.show()

# By hand, mirroring what SIIResult.plot() draws:
pos = np.arange(result.frequencies.size)
weighted = result.band_audibility * result.band_importance
fig, ax = plt.subplots()
ax.bar(pos, result.band_audibility, color="#c6dbef", label=r"Band audibility $A_i$")
ax.bar(pos, weighted / weighted.max(), width=0.5, color="#1f77b4",
       label=r"Importance-weighted $I_i A_i$ (scaled)")
ax.set_xticks(pos)
ax.set_xticklabels([f"{f:g}" for f in result.frequencies], rotation=45, ha="right")
ax.set_xlabel("One-third-octave band [Hz]")
ax.set_ylabel("Band audibility")
ax.set_title(f"SII = {result.sii:.2f}")
ax.legend()
plt.show()
```

</details>

A raised hearing threshold (`threshold=`) lifts the equivalent internal noise
and lowers the index, exactly as added masking noise does. The
`SIIResult` also carries the per-band masking `Zi`, disturbance `Di`, audibility
`Ai` and importance `Ii`, and its `.plot()` renders the figure above.

## 4. Vocal effort

Talkers raise their voice in noise, and the standard gives four **standard
speech spectra** for the vocal efforts *normal*, *raised*, *loud* and *shout*
(ANSI S3.5-1997 Table 3). Passing the effort name selects the corresponding
spectrum; speaking louder lifts the whole spectrum and, in a fixed noise, raises
the index.

```python
import numpy as np
from phonometry import hearing

# The same broadband noise, four vocal efforts.
noise = np.array([48.0, 47.0, 46.0, 44.0, 42.0, 40.0, 38.0, 36.0, 34.0,
                  32.0, 30.0, 28.0, 26.0, 24.0, 22.0, 20.0, 18.0, 16.0])
for effort in hearing.sii.VOCAL_EFFORTS:
    print(effort, round(hearing.speech_intelligibility_index(effort, noise).sii, 2))
# normal 0.12 | raised 0.36 | loud 0.59 | shout 0.79

print(hearing.standard_speech_spectrum("loud")[8])  # 42.16 dB SPL at 1 kHz
```

The four spectra are also available as one plottable result:
`standard_speech_spectra()` returns a `StandardSpeechSpectrum` carrying the band
centre frequencies and the per-effort band levels, and its `.plot()` draws them
as one labelled family on the one-third-octave band axis.

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/standard_speech_spectrum_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/standard_speech_spectrum.svg" alt="The four ANSI S3.5-1997 standard speech spectra (normal, raised, loud and shout) as one labelled family: the standard speech spectrum level in dB SPL over the 18 one-third-octave bands from 160 Hz to 8000 Hz, each higher vocal effort lifting the whole spectrum" width="90%"></picture>

<details>
<summary>Show the code for this figure</summary>

```python
import numpy as np
import matplotlib.pyplot as plt
from phonometry import hearing

# One line: the whole ANSI S3.5-1997 Table 3 family.
hearing.standard_speech_spectra().plot()
plt.show()

# By hand, mirroring what StandardSpeechSpectrum.plot() draws:
res = hearing.standard_speech_spectra()
pos = np.arange(res.frequencies.size)
fig, ax = plt.subplots()
for effort, levels in zip(res.vocal_efforts, res.levels):
    ax.plot(pos, levels, "o-", label=effort.capitalize())
ax.set_xticks(pos)
ax.set_xticklabels([f"{f:g}" for f in res.frequencies], rotation=45, ha="right")
ax.set_xlabel("One-third-octave band [Hz]")
ax.set_ylabel("Speech spectrum level [dB SPL]")
ax.legend()
plt.show()
```

</details>

The same four spectra feed the index: in a fixed broadband noise, each higher
vocal effort lifts the speech spectrum and raises the SII.

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/sii_vocal_efforts_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/sii_vocal_efforts.svg" alt="Two panels. Left: the four ANSI S3.5-1997 standard speech spectra (normal, raised, loud and shout) over the 18 one-third-octave bands from 160 Hz to 8000 Hz, each higher vocal effort lifting the whole spectrum. Right: the resulting Speech Intelligibility Index in a fixed broadband noise, rising from 0.12 (normal) through 0.36 and 0.59 to 0.79 (shout)" width="96%"></picture>

<details>
<summary>Show the code for this figure</summary>

```python
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.ticker import NullFormatter
from phonometry import hearing

# The four ANSI S3.5-1997 Table 3 spectra and the fixed broadband noise above.
noise = np.array([48.0, 47.0, 46.0, 44.0, 42.0, 40.0, 38.0, 36.0, 34.0,
                  32.0, 30.0, 28.0, 26.0, 24.0, 22.0, 20.0, 18.0, 16.0])
efforts = hearing.sii.VOCAL_EFFORTS         # ("normal", "raised", "loud", "shout")
freqs = hearing.sii.BAND_CENTERS            # the 18 one-third-octave band centres

fig, (ax_s, ax_i) = plt.subplots(1, 2, figsize=(12, 5))

# Left: each higher vocal effort lifts the whole speech spectrum.
for effort in efforts:
    ax_s.plot(freqs, hearing.standard_speech_spectrum(effort), "o-",
              label=effort.capitalize())
ax_s.set_xscale("log")
ax_s.set_xticks(list(freqs))
ax_s.set_xticklabels([f"{f:g}" for f in freqs], rotation=45, ha="right")
ax_s.xaxis.set_minor_formatter(NullFormatter())
ax_s.set_xlabel("One-third-octave band [Hz]")
ax_s.set_ylabel("Speech spectrum level [dB SPL]")
ax_s.legend()

# Right: the SII each spectrum reaches in the fixed noise.
sii = [hearing.speech_intelligibility_index(e, noise).sii for e in efforts]
pos = np.arange(len(efforts))
ax_i.bar(pos, sii)
ax_i.set_xticks(pos)
ax_i.set_xticklabels([e.capitalize() for e in efforts])
ax_i.set_ylim(0.0, 1.0)
ax_i.set_ylabel("Speech Intelligibility Index")
plt.show()
```

</details>

The vocal-effort names work anywhere a speech spectrum is expected, including as
the first argument to `speech_intelligibility_index`.

## 5. SII or STI?

The two speech metrics answer different questions from different measurements,
and each is blind to what the other captures:

| | SII (ANSI S3.5) | STI (IEC 60268-16) |
| :--- | :--- | :--- |
| Question answered | Is enough of the speech spectrum *audible* at the listener's ear? | How much of the speech *envelope* does the transmission channel preserve? |
| Inputs | Speech, noise and hearing-threshold spectra (18 one-third-octave equivalent spectrum levels) | An impulse response (indirect) or a STIPA recording through the channel (direct) |
| Band machinery | Band-importance weighting `Ii` applied to the band audibility `Ai` | Modulation transfer function m(F) per octave band, converted to an effective SNR |
| Captures | Steady noise, upward spread of masking, hearing loss, vocal effort, level distortion | Reverberation, echoes, noise and (measured directly) non-linear processing |
| Blind to | Reverberation and any time-domain smearing: a fully audible but hopelessly reverberant channel still scores high | Individual hearing status: hearing-impaired listeners need specific corrections |
| Typical use | Audiology, hearing aids and protectors, noise-control targets at a listener position | PA systems, intercoms and rooms: rating a transmission channel end to end |

The same space can pass one and fail the other. A quiet, highly reverberant
atrium is an SII near 1 with a poor STI; a dry office flooded by ventilation
noise can rate an acceptable STI from its impulse response while the SII (and
the noise-aware STI, via `snr=` or `level=`) reveals that little of the speech
spectrum clears the noise. When both mechanisms are in play, compute both; the
inputs are cheap once the room and the noise have been measured. See the
[Speech Transmission Index guide](https://jmrplens.github.io/phonometry/guides/speech-transmission/) for the STI side.

## 6. ANSI S3.5-1997 report (`.report()`)

`SIIResult.report(path)` renders a one-page PDF fiche laid out like a
speech-audibility report: a standard-basis line, an optional metadata header
block, a per-one-third-octave-band table of the equivalent speech spectrum
*E*′<sub>i</sub>, the Table 3 band-importance function *I*<sub>i</sub> and the
band-audibility function *A*<sub>i</sub> beside the audibility and
importance-weighted contribution bars (the result's own `.plot()`), the boxed
`SII = X` single number, an optional verdict row and a footer with the fixed
disclaimer. It uses the same `ReportMetadata` container (documented under
[Field insulation](https://jmrplens.github.io/phonometry/guides/insulation-field/#report-metadata-reportmetadata)) and
rendering engine as the ISO 717 insulation fiche; a supplied `requirement` is
read as the minimum required SII (a higher SII passes). `verbose=True` adds the
equivalent disturbance spectrum level *D*<sub>i</sub> column. Rendering needs
reportlab (`pip install phonometry[report]`); only `engine="reportlab"` is
supported. Pass `language="es"` for a Spanish fiche.

```python
from phonometry import hearing, ReportMetadata

res = hearing.speech_intelligibility_index(speech, noise, threshold=threshold)
res.report(
    "sii_fiche.pdf",
    metadata=ReportMetadata(
        specimen="Conversational speech in low-frequency ambient noise",
        measurement_standard="ANSI S3.5-1997",
        laboratory="Phonometry Reference Laboratory",
        requirement=0.75,            # minimum required SII (a higher SII passes)
    ),
)
```

The example fiche, regenerated with `make reports`, is kept rendered in the
repository. Click the preview to open the PDF:

[![ANSI S3.5-1997 SII example report: a metadata header, a one-third-octave-band table of the equivalent speech spectrum, band importance and band audibility, the audibility bars, the boxed SII = 0.851 single number and a PASS verdict against a 0.75 minimum](https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/reports/ansi_s3_5_sii_example.webp)](https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/reports/ansi_s3_5_sii_example.pdf)

*Speech intelligibility index fiche (`SIIResult.report`), the SII with its per-band audibility.*

## References

- French, N. R., & Steinberg, J. C. (1947). Factors governing the
  intelligibility of speech sounds. *The Journal of the Acoustical Society of
  America*, 19(1), 90-119.
  [doi:10.1121/1.1916407](https://doi.org/10.1121/1.1916407).
  The articulation-band experiments that the band-importance function of
  section 1 descends from.

## Standards

ANSI S3.5-1997 (R2017), *American National Standard Methods for
the Calculation of the Speech Intelligibility Index*: the one-third-octave-band
method (18 bands), band-importance function (Table 3), standard speech spectrum
level and reference internal noise (Table 3), and the masking, disturbance and
band-audibility procedure (clause 5) and the index (clause 6).

---


<!-- source: docs/objective-intelligibility.md | canonical: https://jmrplens.github.io/phonometry/guides/objective-intelligibility/ -->

# Objective intelligibility (STOI & ESTOI)

**STOI** and **ESTOI** are correlation-based objective intelligibility measures.
Each compares a clean reference to a degraded or processed version of the same
speech and returns a scalar with a monotonic relation to the fraction of words
a listener would understand: `1` when the degraded signal equals the clean one,
and near `0` for uncorrelated noise. Unlike the
[Speech Transmission Index](https://jmrplens.github.io/phonometry/guides/speech-transmission/), which characterises a
transmission channel, and the [Speech Intelligibility Index](https://jmrplens.github.io/phonometry/guides/speech-intelligibility/),
which predicts audibility from spectra, STOI and ESTOI work directly on the two
waveforms, which makes them the standard yardstick for **time-frequency
weighted** speech: noise reduction, source separation and binary-mask
processing, where separating the clean speech from its distortion is not
straightforward.

- **STOI** (Taal, Hendriks, Heusdens and Jensen, 2011) averages the correlation
  between the clean and degraded short-time temporal envelopes over
  one-third-octave bands and 384 ms segments, after a per-segment normalisation
  and a signal-to-distortion clipping.
- **ESTOI** (Jensen and Taal, 2016) mean- and variance-normalises the
  short-time band-by-frame spectrogram over both its rows (band envelopes) and
  its columns (spectra), so the intermediate measure is a spectral correlation.
  It tracks intelligibility better under **modulated maskers** and competing
  talkers, where STOI's band-independent correlation misses the benefit of the
  quiet gaps.

> [!NOTE]
> `pystoi` (a public reimplementation of the authors' MATLAB) is used only as
> an external cross-check in the test suite. phonometry reimplements both
> measures from the two papers and never imports `pystoi` at runtime; the two
> agree to well under `1e-3` on shared inputs.

## 1. The shared front end

Both measures run the same processing before they diverge (Taal et al. 2011,
Section II):

1. **Resample to 10 kHz.** A rate chosen to cover the speech-bearing range; the
   library resamples internally, so the sample rate of the inputs is free.
2. **Short-time transform.** 256-sample (25.6 ms) frames, 50 % overlap, a Hann
   window, zero-padded to a 512-point DFT.
3. **Remove silent frames.** Frames whose *clean* energy is more than 40 dB
   below the loudest clean frame carry no intelligibility and are dropped from
   both signals.
4. **One-third-octave grouping.** The DFT magnitudes are grouped into 15 bands
   from a lowest centre of 150 Hz, giving a band-by-frame envelope
   spectrogram.
5. **384 ms segments.** 30-frame sliding segments are the unit of comparison,
   long enough to carry the slow modulations that matter for intelligibility.

```python
import numpy as np
from phonometry import stoi

fs = 16000
rng = np.random.default_rng(0)
# A clean reference and a noisy version at ~5 dB SNR.
clean = rng.standard_normal(3 * fs)          # stand-in for a speech waveform
noise = rng.standard_normal(3 * fs)
degraded = clean + 0.56 * noise

d = stoi(clean, degraded, fs)
print(round(d.value, 3))              # a scalar in roughly [0, 1]
print(stoi(clean, clean, fs).value)   # 1.0  (a signal against itself)
```

## 2. STOI: envelope correlation with clipping

For every band and segment STOI normalises the degraded envelope to the clean
one, clips it at a lower signal-to-distortion bound (`beta = -15` dB) so that a
fully degraded unit cannot drag the score below its floor, and takes the
sample correlation of the two envelopes (Taal et al. 2011, Eqs. 3-6). The index
is the average of those intermediate correlations over all bands and segments
(Eq. 6). Because the normalisation divides out a per-segment gain, STOI is
**invariant to the overall playback level** of the degraded signal.

```python
import numpy as np
from phonometry import stoi

fs = 10000
rng = np.random.default_rng(1)
clean = rng.standard_normal(3 * fs)
# Higher SNR gives a higher STOI: the relation is monotonic.
for snr_db in (-10, 0, 10, 20):
    g = 10.0 ** (-snr_db / 20.0)
    print(snr_db, round(stoi(clean, clean + g * rng.standard_normal(clean.size), fs).value, 3))
```

The `STOIResult` carries the per-band mean correlation (`band_scores`) and the
per-segment scores (`segment_scores`) that average to `value`, and its
`.plot()` draws the per-band intermediate correlation.

## 3. ESTOI: spectral correlation for modulated maskers

ESTOI (`extended=True`) replaces the band-independent correlation with a
joint spectro-temporal one. Within each 384 ms segment it normalises the
spectrogram rows (the band envelopes) and then the columns (the per-frame
spectra) to zero mean and unit norm, and averages the correlation of the
normalised columns (Jensen and Taal 2016, Eqs. 4-8). Making the columns compete
means a masker that leaves quiet gaps, where the clean speech is briefly
audible, is credited for the speech glimpsed there, which STOI's per-band
average largely misses.

```python
from phonometry import stoi

estoi = stoi(clean, degraded, fs, extended=True)
print(round(estoi.value, 3))
```

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/stoi_intelligibility_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/stoi_intelligibility.svg" alt="Two panels of intelligibility index versus SNR from -15 to 20 dB. Left (STOI): the stationary-masker and modulated-masker curves nearly overlap, so STOI barely separates the two maskers. Right (ESTOI): the modulated-masker curve sits clearly above the stationary one across the whole SNR range, so ESTOI credits the speech glimpsed in the masker's quiet gaps" width="90%"></picture>

<details>
<summary>Show the code for this figure</summary>

```python
import numpy as np
import matplotlib.pyplot as plt
from phonometry import stoi

fs = 10000
rng = np.random.default_rng(20)
t = np.arange(3 * fs) / fs
# A speech-like clean signal: amplitude-modulated formant-ish tones.
clean = np.zeros_like(t)
for f0 in (200.0, 400.0, 700.0, 1100.0, 1800.0, 2600.0):
    depth = 0.5 * (1.0 + np.sin(2 * np.pi * rng.uniform(2.0, 6.0) * t + rng.uniform(0.0, 2 * np.pi)))
    clean += depth * np.sin(2 * np.pi * f0 * t + rng.uniform(0.0, 2 * np.pi))
p_clean = np.sqrt(np.mean(clean ** 2))

base = rng.standard_normal(clean.size)
gate = 0.5 * (1.0 + np.sign(np.sin(2 * np.pi * 5.0 * t)))   # 5 Hz on/off gate
modulated = base * (0.05 + 0.95 * gate)
snrs = np.arange(-15.0, 20.1, 5.0)

def curve(masker, extended):
    p_m = np.sqrt(np.mean(masker ** 2))
    out = []
    for snr in snrs:
        g = p_clean / (p_m * 10.0 ** (snr / 20.0))
        out.append(stoi(clean, clean + g * masker, fs, extended=extended).value)
    return out

fig, (a, b) = plt.subplots(1, 2, figsize=(12, 5), sharey=True)
for ax, extended, title in ((a, False, "STOI"), (b, True, "ESTOI")):
    ax.plot(snrs, curve(base, extended), "o-", label="Stationary masker")
    ax.plot(snrs, curve(modulated, extended), "s--", label="Modulated masker")
    ax.set_title(title); ax.set_xlabel("SNR [dB]"); ax.set_ylim(0, 1); ax.legend()
a.set_ylabel("Intelligibility index")
plt.show()
```

</details>

## 4. Which measure, and when

| | STOI | ESTOI |
| :--- | :--- | :--- |
| Intermediate quantity | Per-band envelope correlation, clipped | Row- and column-normalised spectral correlation |
| Level invariance | Yes (per-segment normalisation) | Yes (per-row and per-column normalisation) |
| Stationary maskers | Well validated | Well validated |
| Modulated maskers, competing talkers | Underrates the glimpsing benefit | Tracks it |
| Cost | Lower | A little higher |

For additive stationary noise the two are interchangeable and STOI is the
lighter default; when the interference fluctuates in time, or when comparing
processors that reshape the speech in time and frequency, prefer ESTOI.

## See also

- [Speech Transmission Index](https://jmrplens.github.io/phonometry/guides/speech-transmission/): rates a transmission
  channel from its impulse response or a STIPA recording.
- [Speech Intelligibility Index](https://jmrplens.github.io/phonometry/guides/speech-intelligibility/): predicts
  intelligibility from speech, noise and hearing-threshold spectra.
- [Filter banks](https://jmrplens.github.io/phonometry/guides/filter-banks/): the one-third-octave bands the front end
  groups the DFT into.

---


<!-- source: docs/intensity.md | canonical: https://jmrplens.github.io/phonometry/guides/intensity/ -->

# Sound Intensity (p-p method)

Sound *pressure* tells you how loud a point is; sound **intensity** tells
you where the energy is *going*. It is the acoustic power flux (W/m²), a
signed vector quantity, which is why intensity probes can localize sources,
separate them from background noise and measure sound power in situ
(ISO 9614) where a pressure measurement alone cannot.

## The two-microphone principle (IEC 61043)

A p-p probe holds two matched microphones a small distance Δr apart. The
pressure at the probe center is their mean, and the particle velocity comes
from the pressure *gradient* (Euler's equation, finite-difference form):

$$
p = \frac{p_1 + p_2}{2}, \qquad
u = -\frac{1}{\rho_0\ \Delta r}\int (p_2 - p_1)\ dt, \qquad
I = \overline{p\ u}
$$

In practice the estimator works in the frequency domain through the
cross-spectrum of the two channels (the standard's equivalent form):

$$
I(f) = -\ \frac{\mathrm{Im}\lbrace G_{12}(f)\rbrace}{2\pi f\ \rho_0\ \Delta r}
$$

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/diagram_pp_probe_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/diagram_pp_probe.svg" alt="Two-microphone p-p intensity probe with the spacer distance and the measurement axis" width="92%"></picture>

```python
import numpy as np
from phonometry import emission

fs = 48000
rng = np.random.default_rng(0)
# The two probe-microphone pressures in Pa, p1 closest to the source.
#   In a real measurement these are your two calibrated probe recordings;
#   synthesized here (p2 = p1 delayed one sample) so the guide runs.
p1 = 0.02 * rng.standard_normal(fs)
p2 = np.concatenate(([0.0], p1[:-1]))   # p2 = p1 delayed one sample

res = emission.sound_intensity(p1, p2, fs, spacing=0.012, fraction=3,
                      limits=[100, 2500])
print(res.total_intensity_level, res.total_direction)      # LI [dB], ±1
print(res.frequency, res.intensity_level)                  # per band
res.plot()   # Lp vs LI per band + the pressure-intensity index (needs matplotlib)
```

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/intensity_demo_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/intensity_demo.svg" alt="Third-octave pressure and intensity levels for a plane progressive wave versus a standing wave" width="92%"></picture>

*Left: in a plane progressive wave all pressure is transported, so
L_I ≈ L_p. Right: a standing wave carries (almost) no net energy, so the
pressure is high but the intensity collapses. The gap L_p − L_I is the
**pressure-intensity index**, the fundamental quality indicator of every
intensity measurement.*

<details>
<summary>Show the code for this figure</summary>

```python
import matplotlib.pyplot as plt
import numpy as np
from phonometry import emission

fs = 48000
rng = np.random.default_rng(0)
# The two probe-microphone pressures in Pa, p1 closest to the source.
#   In a real measurement these are your two calibrated probe recordings;
#   synthesized here (p2 = p1 delayed one sample) so the guide runs.
p1 = 0.02 * rng.standard_normal(fs)
p2 = np.concatenate(([0.0], p1[:-1]))   # p2 = p1 delayed one sample
res = emission.sound_intensity(p1, p2, fs, spacing=0.012, fraction=3,
                      limits=[100, 2500])

# res is the IntensityResult computed in the example above.
# One line — Lp vs LI per band with the pressure-intensity index on a twin axis:
res.plot()
plt.show()

# By hand, from the per-band fields the result carries — mirroring what
# IntensityResult.plot() draws (bar label, merged twin-axis legend, δpI title):
fig, ax = plt.subplots()
ax.semilogx(res.frequency, res.pressure_level, "o-", label="Pressure level Lp")
ax.semilogx(res.frequency, res.intensity_level, "s--", label="Intensity level LI")
ax.set_xlabel("Frequency [Hz]")
ax.set_ylabel("Level [dB]")
twin = ax.twinx()
twin.bar(res.frequency, res.pressure_intensity_index,
         width=res.frequency * 0.2, color="#2ca02c", alpha=0.25,
         label="δpI = Lp − LI")
twin.set_ylabel("Pressure-intensity index δpI [dB]")
# Merge both axes' handles into a single legend, exactly as .plot() does:
lines, labels = ax.get_legend_handles_labels()
tlines, tlabels = twin.get_legend_handles_labels()
ax.legend(lines + tlines, labels + tlabels)
ax.set_title(f"Lp vs LI  (total δpI = {res.total_pressure_intensity_index:.1f} dB)")
plt.show()
```

</details>

The same contrast plays out dynamically below: the pressure and velocity
phasors of a progressive and a standing wave, with the instantaneous
intensity averaging to a net flow in one case and to zero in the other.

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/anim_instantaneous_intensity_dark.gif"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/anim_instantaneous_intensity.gif" alt="Animation: a two-microphone p-p probe with rotating pressure and velocity phasors; the instantaneous intensity arrow flips while its running average settles to a net flow for the progressive wave and to zero for the standing wave" width="640" height="360" loading="lazy"></picture>

[Watch the high-resolution video (WebM)](https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/anim_instantaneous_intensity.webm)

## Knowing when to trust the number

Two physical limits bound every p-p measurement, and the result object
carries both:

- **High frequency**: the finite-difference gradient underestimates I by
  $\sin(k\Delta r)/(k\Delta r)$, verified in CI against IEC 61043 Table 3.
  `IntensityResult.bias_correction` provides the factor and
  `max_valid_frequency` (≈ 0.1·c/Δr; 2.9 kHz for a 12 mm spacer) the
  practical ceiling. Larger spacers reach lower frequencies, smaller ones
  higher.
- **Reactive fields**: when `pressure_intensity_index` (F2 in ISO 9614-1)
  approaches the probe's residual index δ_pI0, phase errors dominate.

Over a measurement surface, the ISO 9614-1 Annex A field indicators grade the
scan itself. **F2**, the surface pressure-intensity indicator, is the surface
pressure level minus the level of the mean *magnitude* of the normal
intensity: the larger it is, the closer the measurement sits to the probe's
phase-error floor. **F3**, the negative partial power indicator, is the same
difference taken with the *signed* mean intensity: F3 − F2 > 0 reveals power
flowing inward through parts of the surface. **F4**, the field non-uniformity
indicator, is the normalised spread of the per-position intensities: the
larger it is, the more measurement positions the surface needs. Together with
the dynamic-capability criterion they are available directly:

```python
import numpy as np
from phonometry import emission

# Per-position measurements over the ISO 9614-1 measurement surface
pressure_levels = np.array([74.1, 73.8, 74.5, 73.2])       # Lp per position (dB)
normal_intensity = np.array([1.2e-5, 1.0e-5, 1.4e-5, 0.9e-5])  # signed In per position (W/m²)

fi = emission.field_indicators(pressure_levels, normal_intensity)
print(round(fi.f2, 2), round(fi.f3, 2), round(fi.f4, 3))   # 3.41 3.41 0.197
ld = emission.dynamic_capability_index(18.0)   # δpI0 = 18 dB → Ld = δpI0 − K
print(ld, ld > fi.f2)                                      # 8.0 True (criterion 1)
```

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/anim_intensity_scan_power_dark.gif"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/anim_intensity_scan_power.gif" alt="Animation: a p-p probe traces the serpentine scan over the top face of the measurement box while the normal-intensity arrows appear behind it, and the partial powers of the five faces accumulate into the sound power level L_W" width="640" height="360" loading="lazy"></picture>

[Watch the high-resolution video (WebM)](https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/anim_intensity_scan_power.webm)

### The margin over the residual index

The two channels of any real probe and analyzer are never perfectly phase
matched. Feed both channels the *same* signal (the residual-intensity test
of IEC 61043): the true intensity is exactly zero, yet the mismatch reports
a small false intensity. The gap between the pressure level and that false
intensity level is the **residual pressure-intensity index** `δpI0`, the
instrument's phase-error floor expressed as an index; IEC 61043 grades
probes and processors (class 1 / class 2) chiefly by it.

In the field, the measured index `δpI = Lp − LI` says how far the pressure
level stands above the level of the net flow, and the systematic error of
the intensity estimate is bounded by the margin between the two indices:

$$
\varepsilon = 10 \log_{10}\!\left( 1 \pm 10^{(\delta_{pI} - \delta_{pI0})/10} \right)
$$

A 10 dB margin keeps the bias within about 0.5 dB and a 7 dB margin within
about 1 dB; these are precisely the bias factors `K` of ISO 9614, and the
**dynamic capability** `Ld = δpI0 − K` is the largest field index the
instrument can afford at a given grade. Read it as a budget: every decibel
the field's `δpI` rises spends a decibel of margin, and when `δpI` reaches
`δpI0` the reading is pure phase error, of either sign. This is why the
pressure-intensity index, not the microphone quality, gates the achievable
accuracy of every intensity measurement.

### Reactive fields near sources

Close to a source the field turns **reactive**: pressure and particle
velocity drift toward quadrature, so a large pressure carries little net
flow. For a small source the quadrature component grows as `1/(kr)`; at
100 Hz and 0.25 m from the source it is already about twice the active one,
and `δpI` climbs just as it does in the standing wave of the figure above.
The same happens between a machine and a hard reflecting surface, and in
reverberant rooms where the diffuse field raises pressure without
transporting energy outward. This is why ISO 9614-1 keeps the measurement
surface on average more than 0.5 m away from the source, and why, when a
scan fails the dynamic-capability criterion, moving the surface outward or
adding absorption to the room usually lowers `F2` below `Ld` more cheaply
than better hardware.

### Choosing the spacer

The spacer sets both ends of the usable band, in opposite directions:

- **The top end is geometry.** The finite difference underestimates the
  gradient by `sin(kΔr)/(kΔr)`, so the ceiling scales as `1/Δr`:
  `max_valid_frequency ≈ 0.1·c/Δr` keeps the bias within about 0.3 dB,
  giving roughly 5.7 kHz for a 6 mm spacer, 2.9 kHz for 12 mm and 690 Hz
  for 50 mm (`bias_correct=True` undoes the known bias somewhat beyond
  that).
- **The bottom end is phase.** A progressive wave puts only
  `360·f·Δr/c` degrees of true phase across the spacer, 0.8° at 63 Hz over
  12 mm, while the channel mismatch stays fixed. Lowering the frequency
  shrinks the signal, not the error, so the margin over `δpI0` collapses at
  low frequency. A larger spacer buys back that margin: the IEC 61043
  residual-index requirements scale as `10·lg(Δr/25 mm)`, so doubling the
  spacer is worth 3 dB of low-frequency margin.

No single spacer covers the full audio range: 6 mm suits high-frequency
work, 50 mm low-frequency work, and the common 12 mm covers the mid band.
Wide-band surveys are measured twice with two spacers and the band results
merged; whichever spacer is fitted, verify `δpI0` with that spacer in
place, since the index belongs to the probe-spacer-analyzer chain, not to
the microphones alone.

### `sound_intensity()` parameters

| Parameter | Type | Units | Range / default | Notes |
| :--- | :--- | :--- | :--- | :--- |
| `p1`, `p2` | 1D arrays | Pa | equal length | Microphone closer to the source first; reversing them flips the sign |
| `fs` | int | Hz | > 0 | |
| `spacing` | float | m | > 0 | Microphone separation Δr (typ. 6/12/50 mm) |
| `rho` | float | kg/m³ | default `1.204` | Air density |
| `c` | float | m/s | default `343.0` | Speed of sound (bias/validity estimates) |
| `fraction` | int, optional | — | `1`, `3` or `None` (default) | Octave/third-octave band integration |
| `limits` | list, optional | Hz | default library band range | Band analysis limits |
| `bias_correct` | bool | — | default `False` | Apply the per-bin $(k\Delta r)/\sin(k\Delta r)$ correction (IEC 61043 §7.3) before summing, so band/broadband totals stop under-reading as $f \to$ `max_valid_frequency`; bins past the first null are left uncorrected. The per-band `bias_correction` factor is reported either way |

See [Theory](https://jmrplens.github.io/phonometry/reference/theory/signal-analysis/) for the derivations and [Calibration](https://jmrplens.github.io/phonometry/guides/calibration/)
for absolute scaling of the two channels.

## References

- Fahy, F. J. (1995). *Sound intensity* (2nd ed.). E&FN Spon.
  ISBN 978-0-419-19810-9.
  [doi:10.4324/9780203475386](https://doi.org/10.4324/9780203475386).
  The monograph on the subject: active and reactive intensity, the p-p
  estimator and the phase-mismatch error budget behind this page.
- International Electrotechnical Commission. (1993). *Electroacoustics —
  Instruments for the measurement of sound intensity — Measurements with
  pairs of pressure sensing microphones* (IEC 61043:1993; adopted in Europe
  as EN 61043:1994).
  [IEC webstore](https://webstore.iec.ch/en/publication/4353).
  The instrument standard: the cross-spectral estimator, the
  residual-intensity test behind `δpI0` and the class 1 / class 2
  requirements.
- International Organization for Standardization. (1993). *Acoustics —
  Determination of sound power levels of noise sources using sound
  intensity — Part 1: Measurement at discrete points* (ISO 9614-1:1993).
  [iso.org catalogue](https://www.iso.org/standard/17427.html).
  The field indicators F2 to F4, the dynamic-capability criterion and the
  0.5 m surface-distance rule.

## Standards

IEC 61043:1993 (EN 61043:1994), *Electroacoustics —
Instruments for the measurement of sound intensity — Measurements with pairs
of pressure sensing microphones*: the two-microphone cross-spectral
intensity estimator, the finite-difference bias correction and the
usable-bandwidth bound (clause 7.3,
Table 3). ISO 9614-1:1993, *Acoustics — Determination of sound power levels
of noise sources using sound intensity — Part 1: Measurement at discrete
points*: the pressure-intensity index, the Annex A field indicators F2, F3
and F4, and the dynamic-capability criterion (Annex B).

---


<!-- source: docs/room-acoustics.md | canonical: https://jmrplens.github.io/phonometry/guides/room-acoustics/ -->

# Room Acoustics

Room acoustics starts from one measurement: the **impulse response** (IR)
between a source and a receiver. Filter it into bands and integrate it, and
it yields reverberation time, clarity and speech intelligibility: everything
about the sound field inside a single room. This page follows that chain in
measurement order: acquiring the IR (ISO 18233), turning it into room
parameters (ISO 3382-1/2), spatial speech metrics for open-plan offices
(ISO 3382-3) and, closing the loop, the sound absorption of a material in a
reverberation room (ISO 354). For sound insulation *between* spaces (the same
IR measured either side of a partition) see the companion
[Field Insulation Measurement and Ratings guide](https://jmrplens.github.io/phonometry/guides/insulation-field/).

## 1. Impulse-response acquisition (ISO 18233)

A room behaves, to a good approximation, as a **linear time-invariant**
system, so everything about it is contained in its IR. You could fire a
pistol and record the tail, but a deterministic excitation played through
a loudspeaker and *deconvolved* recovers the same IR with 20–30 dB more
effective signal-to-noise ratio (ISO 18233). Two excitations are provided.

**Exponential sine sweep (ESS, Annex B).** The instantaneous frequency
rises exponentially,

$$
f(t) = f_1 \left( \frac{f_2}{f_1} \right)^{t/T},
$$

so the time spent per octave is constant and the excitation mimics
pink noise (constant energy per fractional-octave band). The IR is
recovered by linear (zero-padded, non-circular) spectral division,

$$
H = \frac{Y\ \overline{X}}{|X|^2 + \varepsilon},
$$

with a small Tikhonov term $\varepsilon$ guarding the band edges where the
sweep has little energy. Because a low-to-high sweep places harmonic
distortion at *negative* arrival times, distortion separates cleanly from
the linear IR and is discarded by keeping only the causal part. The
`"farina"` method reaches the same result by convolving the recording with
the analytic inverse filter; it assumes the reference sweep was generated
with the default amplitude and fade, so use the spectral method for a
non-unit-amplitude or custom-fade sweep.

**Maximum-length sequence (MLS, Annex A).** An order-`N` binary sequence of
length $2^N-1$ whose circular autocorrelation is a near-perfect delta; the
IR follows from circular cross-correlation of the recorded period with the
sequence. MLS excites at constant amplitude and is quick to average, but it
is more sensitive to time variance (draughts, temperature drift) and cannot
be fed as much power as a sweep. **Prefer the sweep** for rooms and
partitions; reach for MLS when the excitation must be periodic or the
hardware favours a two-level signal.

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/diagram_ir_measurement_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/diagram_ir_measurement.svg" alt="ISO 18233 indirect measurement chain: an ESS sweep or MLS excitation drives a loudspeaker into the room, a microphone captures the response, and deconvolution (correlation or inverse filter) recovers the impulse response" width="92%"></picture>

```python
import numpy as np
from scipy.signal import fftconvolve
from phonometry import room

fs = 48000
# A 3 s, 20 Hz - 20 kHz sweep is a good broadband room excitation
# sweep: excitation you play through the loudspeaker
sweep = room.sweep_signal(fs, 20.0, 20000.0, 3.0)

# Deconvolve the recorded response back to the impulse response
system = np.zeros(fs); system[100] = 1.0; system[2000] = 0.4   # direct + reflection
# recorded: mic capture of the played sweep (here simulated by convolution with a synthetic room)
recorded = fftconvolve(sweep, system)
ir = room.impulse_response(recorded, sweep, fs, method="spectral")
print(int(np.argmax(np.abs(ir))))                    # 100: direct sound recovered

# Farina inverse-filter variant (needs the sweep band)
ir_f = room.impulse_response(recorded, sweep, fs, method="farina", f_range=(20.0, 20000.0))

# Periodic MLS: excite with >= 2 periods, average, cross-correlate
mls = room.mls_signal(16)                                 # length 2**16 - 1 = 65535
rec = fftconvolve(np.tile(mls, 2), system)[: 2 * mls.size]
ir_m = room.mls_impulse_response(rec, mls)
print(int(np.argmax(np.abs(ir_m))))                  # 100
```

`sweep_signal`/`mls_signal` return plain arrays, ready to write to a WAV file
and play. `impulse_response`/`mls_impulse_response` return an
`ImpulseResponseResult`, a drop-in for the raw IR array (`np.asarray(ir)`,
indexing and `ir.size` all keep working, so `room_parameters(ir, fs)` is
unchanged) that also carries the sample rate and method and adds an `.plot()`.
Two more excitations from the transfer-function literature - complementary
Golay pairs with exactly noise-free deconvolution, and sweeps shaped to an
arbitrary target spectrum - live in the
[system-measurement guide](system-measurement.md) and return the same
result types.

**The two excitations.** The exponential sweep sweeps its energy up the
spectrum over the whole signal, while the MLS is a flat-spectrum two-level
sequence, visible as the near-constant magnitude on the right.

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/excitation_signals_dark.webp"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/excitation_signals.webp" alt="ISO 18233 excitation signals: the exponential sine sweep waveform and its spectrogram showing the exponential frequency rise, and a maximum-length sequence with its flat magnitude spectrum" width="96%"></picture>

<details>
<summary>Show the code for this figure</summary>

```python
from phonometry import room
from phonometry import plot_excitation

fs = 48000
sweep = room.sweep_signal(fs, 50.0, 20000.0, 1.0)   # ESS excitation
mls = room.mls_signal(12).astype(float)             # length 2**12 - 1

# One-liner: waveform + spectrogram (sweep), sequence + flat spectrum (MLS)
plot_excitation(sweep, fs, kind="sweep")
plot_excitation(mls, fs, kind="mls")

# By hand: the sweep spectrogram and the MLS magnitude spectrum
import numpy as np
import matplotlib.pyplot as plt

fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 4))
ax1.specgram(sweep, NFFT=1024, Fs=fs, noverlap=512)
ax1.set(xlabel="Time [s]", ylabel="Frequency [Hz]", title="Sweep spectrogram")
spec = np.abs(np.fft.rfft(mls))
freqs = np.fft.rfftfreq(mls.size, d=1.0 / fs)
ax2.semilogx(freqs[1:], 20 * np.log10(spec[1:] / np.median(spec[1:])))
ax2.set(xlabel="Frequency [Hz]", ylabel="Magnitude [dB]", title="MLS spectrum (flat)")
```

</details>

**The recovered impulse response.** Deconvolving the recording gives the
broadband IR: the direct sound, discrete early reflections and the decaying
diffuse tail. Its `.plot()` shows the waveform above and the log-magnitude
envelope with the Schroeder energy-decay curve below: the straight decay
whose slope becomes the reverberation time in §2.

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/anim_sweep_deconvolution_dark.gif"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/anim_sweep_deconvolution.gif" alt="Animation: the exponential sweep crosses the room while its spectrogram builds with delayed copies from the reflections, then the inverse filter collapses the whole recording into the impulse response" width="640" height="360" loading="lazy"></picture>

[Watch the high-resolution video (WebM)](https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/anim_sweep_deconvolution.webm)

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/impulse_response_dark.webp"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/impulse_response.webp" alt="Recovered room impulse response: the normalized waveform with the direct sound and reflections labelled, and below it the log-magnitude envelope in dB with the Schroeder energy-decay curve" width="88%"></picture>

<details>
<summary>Show the code for this figure</summary>

```python
import numpy as np
from scipy.signal import fftconvolve
from phonometry import room

fs = 48000
sweep = room.sweep_signal(fs, 20.0, 20000.0, 1.5)
# A synthetic room: direct sound + two reflections + a decaying diffuse tail
system = np.zeros(int(0.7 * fs))
system[80], system[1400], system[3100] = 1.0, 0.5, 0.32
ir = room.impulse_response(fftconvolve(sweep, system), sweep, fs, length=system.size)

# One-liner: waveform + log-magnitude / Schroeder decay
ir.plot()

# By hand: the normalized log-magnitude envelope in dB
import matplotlib.pyplot as plt
h = np.asarray(ir)
t = np.arange(h.size) / fs
plt.plot(t, 20 * np.log10(np.abs(h) / np.max(np.abs(h))))
plt.ylim(-80, 5)
plt.xlabel("Time [s]"); plt.ylabel("Level re peak [dB]")
```

</details>

**Where to measure.** One IR characterises a single source–receiver pair; a
reported room parameter is the spatial average over several. ISO 3382-1
(performance spaces) asks for at least two source positions and microphones
spaced $\geq 2$ m apart, $\geq 1$ m from any surface, at $1.2$ m
(seated-ear) height; ISO 3382-2 fixes the minimum number of source,
microphone and source–microphone combinations per accuracy grade
(survey / engineering / precision) and asks for microphone positions that
avoid symmetric placements.

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/diagram_room_measurement_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/diagram_room_measurement.svg" alt="Room-acoustics measurement setup: a top-view room plan with two loudspeaker source positions and six microphone positions with the ISO 3382-1 spacing rules, and the ISO 3382-2 table of minimum positions for the survey, engineering and precision grades" width="94%"></picture>

**Averaging across positions.** The reported per-band parameter is the
arithmetic mean over all source-microphone combinations, and the spread
across positions is part of the answer, not noise: quote it alongside the
mean whenever it exceeds the parameter's JND (§2), because a room can meet a
target on average while individual seats sit far outside it. Four placement
mistakes bias the mean itself:

- **Correlated positions.** Microphones closer than 2 m to each other
  sample nearly the same sound field twice, so the average looks more
  stable than it is. The 1 m minimum from any surface likewise avoids the
  pressure build-up near a boundary (and the comb filter of the animation
  below) that colours everything a too-close microphone records.

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/anim_comb_filtering_dark.gif"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/anim_comb_filtering.gif" alt="Animation: as the microphone height changes, the delay between the direct sound and the floor reflection shifts and the comb filter in the frequency response moves with it, which is why measurement position matters near reflecting surfaces" width="640" height="360" loading="lazy"></picture>

[Watch the high-resolution video (WebM)](https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/anim_comb_filtering.webm)

- **Symmetric placements.** In a geometrically symmetric room, mirror-image
  positions receive mirror-image reflection patterns; averaging them adds
  no new information. This is why ISO 3382-2 asks for positions that do not
  sit on symmetry lines.
- **Too close to the source.** Inside the direct field EDT collapses and
  C80 saturates upward no matter what the room does. ISO 3382-2 therefore
  keeps every microphone at least $d_{min} = 2\sqrt{V/(c\,\hat T)}$ from
  the source, with $\hat T$ an estimate of the expected reverberation time
  (about 2.2 m for a 200 m³ classroom with an expected 0.5 s).
- **Low-frequency luck.** Below the Schroeder frequency (§2) each band
  holds only a handful of room modes, and a microphone on a node of one of
  them sees a different decay than a microphone on an antinode. The spread
  of the 63-125 Hz bands across positions is structurally larger; the cure
  is more positions, not a longer excitation.

### `sweep_signal()` / `inverse_filter()` parameters

| Parameter | Type | Units | Range / default | Notes |
| :--- | :--- | :--- | :--- | :--- |
| `fs` | int | Hz | > 0 | Sampling frequency |
| `f1` | float | Hz | > 0, at/below lowest band | Sweep start frequency |
| `f2` | float | Hz | `f1 < f2 <= fs/2` | Sweep stop frequency |
| `seconds` | float | s | any; longer ⇒ more SNR | Sweep duration |
| `amplitude` | float | — | default `1.0` | Peak amplitude |
| `fade` | float | — | `[0, 0.5)`, default `0.01` | Half-Hann fade fraction (kills start/stop transients) |

### `impulse_response()` parameters

| Parameter | Type | Units | Range / default | Notes |
| :--- | :--- | :--- | :--- | :--- |
| `recorded` | 1D array | any | non-empty | Recorded system response |
| `reference` | 1D array | any | non-empty | The emitted sweep |
| `fs` | int | Hz | > 0 | Sample rate |
| `method` | str | — | `'spectral'` (default) / `'farina'` | `'farina'` requires `f_range` |
| `f_range` | (float, float) | Hz | default `None` | `(f1, f2)` of the sweep (Farina only) |
| `regularization` | float | — | default `1e-6` | Tikhonov term as a fraction of peak spectral energy |
| `length` | int, optional | samples | default `len(recorded)` | Samples of causal IR to return |
| `return_full` | bool | — | default `False` | Return the full sequence (distortion in the tail) |

`mls_signal(order)` takes an integer `order` in 2–20 (sequence length
$2^{\text{order}}-1$); `mls_impulse_response(recorded, mls, length=None)`
needs `recorded` to span an integer number of MLS periods.

## 2. Decay analysis and room parameters (ISO 3382-1/2)

The IR is filtered into octave (or one-third-octave) bands and each band is
turned into a **decay curve** by Schroeder backward integration of the
squared IR:

$$
E(t) = \int_t^{\infty} p^2(\tau)\ d\tau, \qquad
L(t) = 10 \log_{10} \frac{E(t)}{E(0)}\ \text{dB}.
$$

Integrating *backwards* removes the fluctuation that plagues a raw squared
IR and yields a smooth curve whose slope is the decay rate. Background
noise would make $E(t)$ level off, so integration is truncated where the
fitted decay line crosses the noise floor and the missing tail is
compensated assuming an exponential decay.

Reverberation times come from a least-squares line fit over an evaluation
range, extrapolated to a full 60 dB drop, $T = -60/\text{slope}$:
**EDT** over 0 to −10 dB (perceived reverberance), **T20** over −5 to −25 dB
and **T30** over −5 to −35 dB. Energy splits at an early/late boundary give
**clarity** and **definition**,

$$
C_{te} = 10 \log_{10} \frac{\int_0^{te} p^2\ dt}{\int_{te}^{\infty} p^2\ dt}\ \text{dB}, \qquad
D_{50} = \frac{\int_0^{0.05} p^2\ dt}{\int_0^{\infty} p^2\ dt},
$$

with $te = 50$ ms → C50 (speech) and $te = 80$ ms → C80 (music), plus the
**centre time** $T_s = \int t\ p^2\ dt / \int p^2\ dt$. Each parameter has a
**just-noticeable difference** (ISO 3382-1 Table A.1: EDT 5 %, C80 1 dB,
D50 0.05, Ts 10 ms) that sets how precisely it is worth reporting.

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/anim_schroeder_dark.gif"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/anim_schroeder.gif" alt="Animation: the tail energy of the squared impulse response fills from the end while the backward integral advances toward t = 0, and the Schroeder decay curve emerges on a companion axis ending with the T20 and T30 regression lines" width="640" height="360" loading="lazy"></picture>

[Watch the high-resolution video (WebM)](https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/anim_schroeder.webm)

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/schroeder_decay_dark.webp"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/schroeder_decay.webp" alt="Squared impulse response with its Schroeder backward-integrated decay curve, and the EDT, T20 and T30 regression windows marked" width="80%"></picture>

*The jagged squared IR (grey) integrates to the smooth Schroeder curve
(blue); the EDT, T20 and T30 windows are fitted on that curve and each
extrapolated to a 60 dB decay.*

```python
import numpy as np
from phonometry import room

fs = 48000
# Single-slope decay with T = 1 s: p^2 = exp(-13.8155 t)  (60/ln(10)/13.8155 = 1)
t = np.arange(fs) / fs
# ir: measured room impulse response; a synthetic single-slope decay stands in here.
ir = np.concatenate([np.zeros(10), np.exp(-13.8155 * t / 2.0)])

time, level = room.decay_curve(ir, fs)                    # Schroeder curve (0 dB at t = 0)

res = room.room_parameters(ir, fs, limits=None)           # broadband single band
print(round(float(res.t30[0]), 2))                   # 1.0  s
print(round(float(res.c80[0]), 2))                   # 3.05 dB
print(round(float(res.d50[0]), 3))                   # 0.499
print(round(float(res.ts[0]) * 1000, 0))             # 72 ms

# Octave bands 125 Hz - 4 kHz (ISO 3382-1 default); use fraction=3 for thirds
octaves = room.room_parameters(ir, fs)
print(octaves.frequency)                             # ~[126, 251, 501, 1000, 1995, 3981]
print(octaves.t30_valid)                             # per-band dynamic-range flags

octaves.plot()               # per-band EDT/T20/T30 + C50/C80 bars (needs matplotlib)
room.decay_curve(ir, fs).plot()   # Schroeder decay with EDT/T20/T30 fit overlays
```

<details>
<summary>Show the code for this figure</summary>

```python
import matplotlib.pyplot as plt
import numpy as np
from phonometry import room

fs = 48000
# Single-slope decay with T = 1 s: p^2 = exp(-13.8155 t)  (60/ln(10)/13.8155 = 1)
t = np.arange(fs) / fs
# ir: measured room impulse response; a synthetic single-slope decay stands in here.
ir = np.concatenate([np.zeros(10), np.exp(-13.8155 * t / 2.0)])
time, level = room.decay_curve(ir, fs)                    # Schroeder curve (0 dB at t = 0)

# One line — Schroeder decay with the EDT/T20/T30 straight-line fits:
decay = room.decay_curve(ir, fs)          # a DecayCurve (still unpacks as time, level)
decay.plot()
plt.show()

# By hand, the decay is just the Schroeder curve; mark the evaluation levels:
fig, ax = plt.subplots()
ax.plot(time, level, color="#1f77b4", label="Schroeder decay")
for db in (-5.0, -25.0, -35.0):      # T20 / T30 evaluation-window edges
    ax.axhline(db, ls=":", alpha=0.4)
ax.set_xlabel("Time [s]")
ax.set_ylabel("Level re steady state [dB]")
ax.set_ylim(top=3.0)
ax.legend()
plt.show()
```

</details>

For this single-slope decay EDT, T20 and T30 all return ≈ 1.0 s, and the
energy parameters match their closed forms (C80 = 3.05 dB, D50 = 0.499,
Ts = 72 ms). A real room has a steeper early slope, so EDT < T30.

**Reading EDT, T20 and T30 against each other.** The three times
extrapolate the same 60 dB decay from different windows, so their
disagreement carries information:

- **T20 ≈ T30** (curvature below 10 %): the decay is close to a single
  straight slope over both evaluation windows, which is consistent with
  (though not proof of) a diffuse field, and either time can stand for
  "the" reverberation time of the band.
- **T30 > T20** (curvature above 10 %): the decay sags, with late energy
  decaying more slowly than early energy. The usual causes are coupled
  volumes (an open door to a corridor or stairwell, a deep balcony or a
  stage house feeding energy back) and strongly uneven absorption that
  leaves one room axis reverberant. No single number describes such a
  decay: report both windows together with the curvature, and treat the
  [statistical predictions](reverberation-prediction.md) with suspicion,
  because their diffuse-field assumption has visibly failed.
- **EDT far from T20/T30**: EDT is fitted where the direct sound and the
  first reflections still dominate, so it varies from seat to seat while
  T30 barely moves. EDT below T30 means the position receives strong early
  energy (close to the source, under a reflector): the room sounds drier
  there than its T30 suggests, because perceived reverberance follows EDT.
  EDT above T30 at one seat points to an echo or a focusing surface
  concentrating late energy there.

**How much decay the noise floor allows.** A fit window is only as good as
the decay range underneath it. The **impulse-to-noise ratio** (INR) is the
level distance between the peak of the band-filtered IR and its noise
floor; the fit window plus a safety margin must fit inside it, which is the
ISO 3382 requirement of at least 35 dB of usable decay range for T20 and
45 dB for T30. An undersized range biases the fitted time upward, toward
the flat tail the noise floor imposes on the decay curve, and the bias
grows quietly before the fit visibly fails (Hak, Wenmaekers, & van
Luxemburg, 2012). `room_parameters` reports the per-band `dynamic_range`
and tightens the acceptance limits to 46 dB (T20) and 54 dB (T30) before
flagging a value valid, so the residual truncation-and-compensation bias of
a flagged-valid time stays inside the 5 % JND. When a band fails its flag,
the order of remedies is: use T20 instead of T30 (its window needs 10 dB
less range under the ISO minima, 8 dB under the tightened flags); raise the
INR at acquisition, since doubling the sweep length or
the number of synchronous averages buys 3 dB each time; and only then fall
back to EDT, never to a fit stretched into the noise.

Below the **Schroeder frequency** $f_s \approx 2000\sqrt{T/V}$ these
decay statistics stop telling the whole story: the field is ruled by
discrete **room modes**. The simulation below drives the same rigid
5 m by 3.5 m room at its (2,1) mode and then between two modes; on
resonance a standing-wave pattern with fixed nodal lines grows until it
dominates the RMS pressure map, off resonance the room still responds,
but the forced field stays weak and never organises into that (2,1)
nodal pattern.

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/anim_fdtd_room_modes_dark.gif"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/anim_fdtd_room_modes.gif" alt="Animation: a 2D FDTD simulation of a 5 by 3.5 metre room driven at the 84 Hz (2,1) mode and at an off-mode frequency; on resonance a standing-wave pattern with fixed nodal lines grows to dominate the RMS pressure map, off resonance the forced response stays weak and disorganised" width="640" height="360" loading="lazy"></picture>

[Watch the high-resolution video (WebM)](https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/anim_fdtd_room_modes.webm)

### `room_parameters()` parameters

| Parameter | Type | Units | Range / default | Notes |
| :--- | :--- | :--- | :--- | :--- |
| `ir` | 1D array | any | non-silent | Measured impulse response |
| `fs` | int | Hz | > 0 | Sample rate |
| `limits` | (float, float) or `None` | Hz | default `(125.0, 4000.0)` | Band-centre limits; `None` = broadband single band |
| `fraction` | int | — | `1` (octave, default) / `3` (third) | Bandwidth fraction |
| `zero_phase` | bool | — | default `False` | Forward-backward octave filtering (ISO 3382-2 §7.3 NOTE, which relaxes $BT > 16$ to $BT > 4$); removes the filter group delay before the backward integration and roughly halves the 125 Hz short-decay T30 bias (~+4.9 % → +2.4 % at $T$ = 0.2 s). `decay_curve` accepts it too |

Returns a `RoomAcousticsResult`: `frequency` (band centres, or `None`
broadband), `edt`/`t20`/`t30` (s), `c50`/`c80` (dB), `d50`, `ts` (s),
`dynamic_range` (dB), the `edt_valid`/`t20_valid`/`t30_valid` flags (ISO
3382-1 §5.3.3: noise ≥ 25 dB below the peak for EDT, tightened to 46 dB for
T20 and 54 dB for T30 so the tail-compensation bias of a flagged-valid value
stays within the 5 % JND) and `curvature`
$C = 100\ (T_{30}/T_{20} - 1)$ % (values above 10 % flag a non-straight
decay). `decay_curve(ir, fs, band=None, fraction=1, zero_phase=False)` returns
just the `(time, level)` curve for one band or the broadband response.

### ISO 3382 report (`.report()`)

`RoomAcousticsResult.report(path)` renders a one-page PDF fiche laid out like a
room-acoustics measurement report (a performance space per ISO 3382-1:2009 or
an ordinary room per ISO 3382-2:2008, both evaluated by the integrated
impulse-response method): a standard-basis line, an optional metadata header
block, the full-width per-band parameter table ($T_{20}$, $T_{30}$, EDT,
$C_{50}$, $C_{80}$, $D_{50}$, $T_s$) above the result's own per-band decay-time
plot (`.plot()`), the boxed mid-frequency reverberation time $T_\text{mid}$
(the mean of the 500 Hz and 1000 Hz octave $T_{30}$) with the mid-frequency EDT
alongside, and a footer with the fixed disclaimer. ISO 3382-1/-2 are
characterisation standards with no intrinsic pass/fail, so a verdict row appears
only when a target mid-frequency reverberation time is supplied through the
metadata's `requirement` field (`ReportMetadata(requirement=...)`, read as the
maximum acceptable $T_\text{mid}$); a broadband result has no 500 Hz / 1000 Hz
octaves to average, so its box and verdict fall back to the plain broadband
$T_{30}$ with no "500-1000 Hz" claim. It uses the same
`ReportMetadata` container as the [ISO 11654 absorption fiche](materials.md#iso-11654-report-report);
the room-specific fields `room_volume`, `source_positions` and
`receiver_positions` populate the header (ISO 3382 requires the room volume and
the number of source and microphone positions to be reported), alongside
`test_room`, `specimen`, `area`, `instrumentation`, `temperature`,
`relative_humidity`, `pressure`, `measurement_standard`, `test_date`,
`laboratory`, `operator`, `report_id` and `notes`. Passing `metadata=None`
produces a bare characterisation fiche. Rendering needs reportlab
(`pip install phonometry[report]`); 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).

```python
from phonometry import room, ReportMetadata

result = room.room_parameters(ir, fs)   # octave bands 125 Hz - 4 kHz
result.report(
    "room_fiche.pdf",
    metadata=ReportMetadata(
        specimen="Small auditorium, unoccupied, fully furnished",
        test_room="Auditorium A",
        room_volume=2830.0, area=340.0,
        source_positions=2, receiver_positions=8,
        measurement_standard="ISO 3382-1",
        temperature=21.0, relative_humidity=45.0,
        laboratory="Phonometry Reference Laboratory",
        requirement=1.3,           # adds a verdict against a target T_mid
    ),
)                                  # T_mid + the per-band parameter table
```

The example fiche, regenerated with `make reports`, is kept rendered in the
repository. Click the preview to open the PDF:

[![ISO 3382 room acoustic parameters example report: metadata header, the octave-band parameter table (T20, T30, EDT, C50, C80, D50, Ts from 125 Hz to 4 kHz) above the per-band decay-time bar plot, boxed mid-frequency T_mid = 1.15 s and a PASS verdict against a 1.3 s target](https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/reports/iso3382_room_acoustics_example.webp)](https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/reports/iso3382_room_acoustics_example.pdf)

*Room acoustic parameters fiche (`RoomAcousticsResult.report`), $T_\text{mid}$ and the per-band table.*

## 3. Open-plan offices (ISO 3382-3)

Open-plan acoustics are about **speech privacy**: how fast a talker's speech
fades to unintelligibility as you walk away. Levels and STI are measured
along a line of workstations (at least 4 positions, 6–10 preferred), and
four single-number quantities summarise the room. The **spatial decay
rate** of A-weighted speech is the slope of the level against
$\lg(r/r_0)$, scaled to a per-doubling figure using only the 2–16 m
positions,

$$
D_{2,S} = -\lg(2)\ b, \qquad L = a + b\ \lg(r/r_0),\ r_0 = 1\ \text{m},
$$

with **Lp,A,S,4m** read off the same line at 4 m. The **distraction
distance** rD (STI = 0.50) and **privacy distance** rP (STI = 0.20) come
from a linear regression of STI against distance. Good offices push rD
below ~5 m; poor ones leave speech distracting past 10 m.

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/diagram_open_plan_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/diagram_open_plan.svg" alt="ISO 3382-3 open-plan measurement line from the source at 1 m along positions from 2 m to 16 m, feeding the four single-number quantities D2,S, Lp,A,S,4m, rD and rP" width="86%"></picture>

```python
import numpy as np
from phonometry import room

r = np.array([2.0, 4.0, 6.0, 8.0, 12.0, 16.0])       # distances from the talker (m)
lp = 65.0 - 7.0 * np.log2(r)                          # A-weighted speech level (dB)
sti = 0.70 - 0.03 * r                                 # STI per position

m = room.open_plan_metrics(r, lp, sti)
print(round(m.d2s, 1), round(m.lp_as_4m, 1))         # 7.0 dB, 51.0 dB
print(round(m.rd, 1), round(m.rp, 1))                # 6.7 m, 16.7 m
```

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/open_plan_decay_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/open_plan_decay.svg" alt="Open-plan spatial decay: A-weighted speech level and STI against source distance on a log axis, with the D2,S regression, the Lp,A,S,4m marker at 4 m and the rD and rP distance crossings" width="80%"></picture>

<details>
<summary>Show the code for this figure</summary>

```python
import matplotlib.pyplot as plt
import numpy as np
from phonometry import room

r = np.array([2.0, 4.0, 6.0, 8.0, 12.0, 16.0])       # distances from the talker (m)
lp = 65.0 - 7.0 * np.log2(r)                          # A-weighted speech level (dB)
sti = 0.70 - 0.03 * r                                 # STI per position
m = room.open_plan_metrics(r, lp, sti)

# One line: the D2,S regression rebuilt from the result fields, with the
# rD / rP crossings marked (the figure above adds the measured points and
# the STI axis on top of it):
m.plot()
plt.show()
```

```python
import matplotlib.pyplot as plt
import numpy as np
from phonometry import room

r = np.array([2.0, 4.0, 6.0, 8.0, 12.0, 16.0])       # distances from the talker (m)
lp = 65.0 - 7.0 * np.log2(r)                          # A-weighted speech level (dB)
sti = 0.70 - 0.03 * r                                 # STI per position
m = room.open_plan_metrics(r, lp, sti)

# Spatial decay: measured Lp,A,S vs distance on a log axis, the D2,S
# regression rebuilt from the result fields, and STI with the rD / rP
# crossings on a twin axis:
b = -m.d2s / np.log10(2.0)                 # regression slope vs lg(r)
a = m.lp_as_4m - b * np.log10(4.0)         # intercept from the 4 m level
rr = np.logspace(np.log10(2.0), np.log10(16.0), 100)

fig, ax = plt.subplots()
ax.semilogx(r, lp, "o", label="Measured Lp,A,S")
ax.semilogx(rr, a + b * np.log10(rr), "--", label=f"D2,S = {m.d2s:.1f} dB")
ax.plot(4.0, m.lp_as_4m, "D", label=f"Lp,A,S,4m = {m.lp_as_4m:.0f} dB")
ax.set_xlabel("Distance from the talker r [m]")
ax.set_ylabel("A-weighted speech level [dB]")
ax.set_xlim(1.8, 20.0)

twin = ax.twinx()
twin.semilogx(r, sti, "s-", color="#2ca02c", label="STI")
twin.axvline(m.rd, ls=":", color="#2ca02c")
twin.axvline(m.rp, ls=":", color="#9467bd")
twin.annotate(f"rD = {m.rd:.1f} m", (m.rd, 0.52))
twin.annotate(f"rP = {m.rp:.1f} m", (m.rp, 0.22))
twin.set_ylabel("STI")
twin.set_ylim(0.0, 1.0)

lines, labels = ax.get_legend_handles_labels()
tl, tlab = twin.get_legend_handles_labels()
ax.legend(lines + tl, labels + tlab, loc="best")
plt.show()
```

</details>

### `open_plan_metrics()` parameters

| Parameter | Type | Units | Range / default | Notes |
| :--- | :--- | :--- | :--- | :--- |
| `positions_m` | 1D array | m | ≥ 4 positions, all > 0 | Source-to-receiver distances |
| `spl_a_speech` | 1D array | dB | same length | A-weighted speech level `Lp,A,S,n` per position |
| `sti_values` | 1D array | — | same length | STI per position (full IEC 60268-16 method) |

Returns an `OpenPlanResult` with `d2s`, `lp_as_4m`, `rd` and `rp`; its
`.plot()` redraws the Clause 6.2 spatial-decay regression from those four
fields and marks `rd` / `rp`.
`d2s`/`lp_as_4m` are `nan` if fewer than two positions fall in 2–16 m;
`rd`/`rp` are `nan` when STI does not decrease with distance. The per-position
STI can itself be measured with the STIPA tools in the
[Speech Transmission Index guide](https://jmrplens.github.io/phonometry/guides/speech-transmission/).

### ISO 3382-3 report (`.report()`)

`OpenPlanResult.report(path)` renders a one-page PDF fiche laid out like an
open-plan-office speech-privacy measurement report: a standard-basis line, an
optional metadata header block, a compact metrics table of the four
single-number quantities of Clause 4 ($D_{2,S}$, $L_{p,A,S,4m}$, the
distraction distance $r_D$ and the privacy distance $r_P$) stacked above the
full-width spatial-decay plot (`.plot()`, the Clause 6.2 regression on the
logarithmic distance axis with the 4 m read-off and the $r_D$ / $r_P$ crossings
marked), the boxed $D_{2,S}$ with the other quantities alongside, and a footer with the
fixed disclaimer. ISO 3382-3 **characterises** a space rather than defining an
intrinsic pass/fail, so a verdict row appears only when a target spatial decay
rate is supplied through the metadata's `requirement` field
(`ReportMetadata(requirement=...)`, read as the minimum acceptable $D_{2,S}$ in
dB, reflecting the informative quality ranges of Annex A where a larger spatial
decay is better; the room passes at or above it). It uses the same
`ReportMetadata` container as the [ISO 3382-1/-2 room-acoustics fiche](#iso-3382-report-report);
the open-plan-specific fields `area` (floor area), `source_positions` and
`receiver_positions` (the number of measurement positions) populate the header,
alongside `client`, `test_room`, `specimen`, `instrumentation`, `temperature`,
`relative_humidity`, `pressure`, `measurement_standard`, `test_date`,
`laboratory`, `operator`, `report_id` and `notes`. Passing `metadata=None`
produces a bare characterisation fiche. The fiche embeds the spatial-decay
chart, so rendering needs both reportlab and 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).

```python
import numpy as np
from phonometry import room, ReportMetadata

r = np.array([2.0, 3.0, 4.0, 6.0, 8.0, 11.0, 16.0])   # distances from the talker (m)
lp = 62.0 - 7.0 * np.log2(r)                           # A-weighted speech level (dB)
sti = 0.65 - 0.03 * r                                  # STI per position

result = room.open_plan_metrics(r, lp, sti)
result.report(
    "open_plan_fiche.pdf",
    metadata=ReportMetadata(
        test_room="Open-plan office B",
        specimen="Furnished, unoccupied, background noise present",
        area=420.0, source_positions=2, receiver_positions=7,
        measurement_standard="ISO 3382-3",
        temperature=22.0, relative_humidity=45.0,
        laboratory="Phonometry Reference Laboratory",
        requirement=7.0,          # adds a verdict against a target D2,S
    ),
)                                 # D2,S + Lp,A,S,4m, rD, rP and the decay curve
```

The example fiche, regenerated with `make reports`, is kept rendered in the
repository. Click the preview to open the PDF:

[![ISO 3382-3 open-plan office acoustics example report: metadata header, the metrics table of the four single-number quantities (D2,S = 7.0 dB per doubling, Lp,A,S,4m = 48.0 dB, rD = 5.0 m, rP = 15.0 m) above the spatial-decay plot on a logarithmic distance axis with the D2,S regression, the 4 m read-off and the rD and rP crossings, boxed D2,S and a PASS verdict against a 7.0 dB target](https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/reports/iso3382_3_open_plan_example.webp)](https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/reports/iso3382_3_open_plan_example.pdf)

*Open-plan office acoustics fiche (`OpenPlanResult.report`), $D_{2,S}$, $L_{p,A,S,4m}$, $r_D$, $r_P$ and the spatial-decay curve.*

## 4. Sound absorption (ISO 354)

The equivalent absorption area `A` that drives `R'`, `L'n`, the ISO 3744 `K2`
environmental correction and the ISO 3741 absorption term is itself measured in
a reverberation room (ISO 354).
Measure the room's reverberation time **empty** ($T_1$) and again **with the
test specimen installed** ($T_2$); the specimen's absorption is the difference
of the two Sabine areas, and dividing by the covered area gives the absorption
coefficient:

$$
A = \frac{55.3\ V}{c\ T} - 4 V m, \qquad
\alpha_s = \frac{A_2 - A_1}{S}, \qquad c = 331 + 0.6\ t ,
$$

with $c$ from the room air temperature $t$ in °C (valid 15–30 °C) and $m$ the
power attenuation coefficient of air (default 0; convert an ISO 9613-1
$\alpha$ in dB/m with `attenuation_from_alpha`). Because edge and diffraction
effects can scatter more energy than the sample's flat area intercepts,
$\alpha_s$ may exceed 1.0 and is never clamped (ISO 354 Clause 3.7).

```python
import numpy as np
from phonometry import materials

# Third-octave reverberation times of a 200 m^3 room, empty (T1) and with a
# 10.8 m^2 absorber sample installed (T2).
t1 = np.array([5.0, 4.0, 3.0])
t2 = np.array([3.0, 2.5, 2.0])

a_empty = materials.absorption_area(t1, volume=200.0, temperature=20.0)
print(np.round(a_empty, 2))                    # [ 6.45  8.06 10.75] m^2

alpha = materials.absorption_coefficient(t1, t2, volume=200.0, sample_area=10.8,
                               temperature1=20.0)
print(np.round(alpha, 3))                      # [0.398 0.448 0.498]
```

`T1` and `T2` are exactly the reverberation times `room_parameters` returns, so
an ISO 3382-2 decay measurement of the empty and treated room flows straight
into `absorption_coefficient`. A room volume below the 150 m³ minimum or a
sample area outside 10–12 m² raises an advisory `AbsorptionWarning`; the result
still returns.

### `absorption_area()` / `absorption_coefficient()` parameters

| Parameter | Type | Units | Range / default | Notes |
| :--- | :--- | :--- | :--- | :--- |
| `t60` / `t1`, `t2` | 1D array | s | > 0 | Reverberation time(s); `t1` empty, `t2` with specimen |
| `volume` | float | m³ | > 0 | Room volume `V` (advisory below 150 m³) |
| `sample_area` | float | m² | > 0 | Area `S` the specimen covers (coefficient only) |
| `temperature` / `temperature1`, `temperature2` | float | °C | default `20.0`, 15–30 | Sets `c` via Eq. (6); `temperature2` defaults to `temperature1` |
| `speed_of_sound` (`…1`, `…2`) | float, optional | m/s | > 0 | Overrides the temperature-derived `c` |
| `m` (`m1`, `m2`) | float or 1D array | 1/m | ≥ 0, default `0` | Air power attenuation coefficient |

`absorption_area()` returns the equivalent absorption area `A` (m²) with the
shape of `t60`; `absorption_coefficient()` returns `alpha_s`;
`attenuation_from_alpha(alpha)` converts an ISO 9613-1 `alpha` (dB/m) to `m`.

## See also

- [Field](https://jmrplens.github.io/phonometry/guides/insulation-field/), [laboratory](https://jmrplens.github.io/phonometry/guides/insulation-lab/) and
  [predicted](https://jmrplens.github.io/phonometry/guides/insulation-prediction/) sound insulation: field,
  laboratory and predicted sound insulation between spaces, and its measurement uncertainty.
- [Sound Power](https://jmrplens.github.io/phonometry/guides/sound-power/): the `LW` methods that consume the
  ISO 354 absorption area (the ISO 3744 `K2` and the ISO 3741 absorption term).
- [Speech Transmission Index](https://jmrplens.github.io/phonometry/guides/speech-transmission/): the STI/STIPA
  measurement that feeds the open-plan `sti_values`.
- [Loudness](https://jmrplens.github.io/phonometry/guides/loudness/) and [Sound Quality Metrics](https://jmrplens.github.io/phonometry/guides/sound-quality/): loudness,
  sharpness and the other perception metrics of what the room delivers.
- [Filter Banks](https://jmrplens.github.io/phonometry/guides/filter-banks/): the IEC 61260 fractional-octave filters
  used for band decay curves and insulation spectra.
- [Levels](https://jmrplens.github.io/phonometry/guides/levels/): energy averaging and the level metrics behind
  source/receiving-room levels.
- [Theory](https://jmrplens.github.io/phonometry/reference/theory/rooms-buildings/): Schroeder integration, regression windows and the
  reference-curve derivation.
- API reference: [`room.room_acoustics`](https://jmrplens.github.io/phonometry/reference/api/rooms/room-acoustics/), [`room.room_ir`](https://jmrplens.github.io/phonometry/reference/api/rooms/room-ir/) and [`room.open_plan`](https://jmrplens.github.io/phonometry/reference/api/rooms/open-plan/).

## References

- Kuttruff, H. (2016). *Room acoustics* (6th ed.). CRC Press.
  [doi:10.1201/9781315372150](https://doi.org/10.1201/9781315372150).
  The reference monograph behind this page: the statistical theory of
  decaying sound fields, the Schroeder frequency and the perceptual room
  parameters of §2.
- Schroeder, M. R. (1965). New method of measuring reverberation time.
  *The Journal of the Acoustical Society of America*, 37(3), 409-412.
  [doi:10.1121/1.1909343](https://doi.org/10.1121/1.1909343).
  The backward-integration method that turns the squared impulse response
  into the smooth decay curve of §2.
- Hak, C. C. J. M., Wenmaekers, R. H. C., & van Luxemburg, L. C. J. (2012).
  Measuring room impulse responses: Impact of the decay range on derived
  room acoustic parameters. *Acta Acustica united with Acustica*, 98(6),
  907-915. [doi:10.3813/aaa.918574](https://doi.org/10.3813/aaa.918574).
  The INR analysis behind the dynamic-range discussion and the tightened
  validity flags of §2.
- International Organization for Standardization. (2009). *Acoustics —
  Measurement of room acoustic parameters — Part 1: Performance spaces*
  (ISO 3382-1:2009).
  [iso.org catalogue](https://www.iso.org/standard/40979.html).
  The parameter definitions, position requirements and just-noticeable
  differences of §2.
- International Organization for Standardization. (2008). *Acoustics —
  Measurement of room acoustic parameters — Part 2: Reverberation time in
  ordinary rooms* (ISO 3382-2:2008).
  [iso.org catalogue](https://www.iso.org/standard/36201.html).
  The accuracy grades, position counts and the minimum source distance of
  the position-averaging discussion in §1.
- International Organization for Standardization. (2012). *Acoustics —
  Measurement of room acoustic parameters — Part 3: Open plan offices*
  (ISO 3382-3:2012).
  [iso.org catalogue](https://www.iso.org/standard/46520.html).
  The open-plan speech-privacy quantities of §3.
- International Organization for Standardization. (2006). *Acoustics —
  Application of new measurement methods in building and room acoustics*
  (ISO 18233:2006).
  [iso.org catalogue](https://www.iso.org/standard/40408.html).
  The swept-sine and MLS acquisition of §1.
- International Organization for Standardization. (2003). *Acoustics —
  Measurement of sound absorption in a reverberation room* (ISO 354:2003).
  [iso.org catalogue](https://www.iso.org/standard/34545.html).
  The reverberation-room absorption measurement of §4.

## Standards

ISO 18233:2006 (application of new measurement methods: the
swept-sine and MLS acquisition of impulse responses); ISO 3382-1:2009 and
ISO 3382-2:2008 (reverberation time and room parameters from the Schroeder
decay); ISO 3382-3:2012 (open-plan office speech metrics); ISO 354:2003
(sound absorption in a reverberation room). Validated against closed-form
decays and the standards' own parameter definitions in the
[conformance report](CONFORMANCE.md).

---


<!-- source: docs/room-image-sources.md | canonical: https://jmrplens.github.io/phonometry/guides/room-image-sources/ -->

# Image sources and the steady-state room field (Kuttruff / Vorländer / Bies)

Where [reverberation-time prediction](reverberation-prediction.md) gives a
single statistical decay rate and [room-acoustics measurement](https://jmrplens.github.io/phonometry/guides/room-acoustics/)
analyses a *measured* impulse response, this page covers the two classical
*predictions* of the sound field in a rectangular room, both in
`phonometry.room`:

- the **image-source room impulse response** (`image_source_rir`): the
  deterministic early reflection pattern built by mirroring a point source in
  the six walls of a shoebox (Kuttruff *Room Acoustics* 4.1; Vorländer
  *Auralization* 11.4); and
- the **steady-state room field** (`steady_state_field` and its parts): the
  statistical direct-plus-reverberant sound pressure level a source of known
  power produces, with the room constant, critical distance and Schroeder
  frequency (Bies *Engineering Noise Control* 6.4; Kuttruff 5.6).

Together they bridge the sound power of `phonometry.emission` and the reverberation
prediction of `phonometry.room`: one gives the full RIR, the other the level
the same room settles to.

## 1. Image-source room impulse response

A rigid or absorbing rectangular room reflects a point source in its walls;
each reflection is exactly the free-field sound of a **mirror image** of the
source. Mirroring a coordinate in a wall (Vorländer Equation (11.36),
`S_n = S − 2 d n`) turns the source into a regular lattice of images, and the
room impulse response is the sum of the direct sound and one delayed,
attenuated impulse per image (Kuttruff Equations (4.4)–(4.5),
`g(t) = Σ_n A_n δ(t − t_n)`).

Image `i` at distance `r_i` from the receiver arrives at `t_i = r_i / c`
(Vorländer Equation (11.38)) with amplitude

```text
A_i = [ Π_walls R_wall^(reflections there) ] · exp(−m r_i / 2) / (4 π r_i)
```

combining the `1 / (4 π r_i)` spherical spreading, the product of the wall
**pressure reflection factors** `R = √(1 − α)` (Vorländer Equation (11.39);
`|R|² = 1 − α` in energy, Kuttruff 4.1) each raised to the number of
reflections that image made off that wall, and the air pressure attenuation
`exp(−m r_i / 2)` over the path (Kuttruff 4.1; `m` the *intensity* attenuation
constant, so intensity falls as `exp(−m r)`).

Along one axis the reflection count off the two walls of an image at lattice
index `n` and mirror parity `p` is `|n − p|` (wall at 0) and `|n|` (wall at
`L`), so the total reflection order is
`|2 n_x − p_x| + |2 n_y − p_y| + |2 n_z − p_z|` (Allen & Berkley 1979). A
shoebox has exactly `(2/3)(2 i₀³ + 3 i₀² + 4 i₀)` audible images up to order
`i₀` (Kuttruff Equation (9.23), e.g. 1560 at order 10), and the temporal
density of reflections grows as `dN/dt = 4 π c³ t² / V` (Kuttruff Equation
(4.6)).

```python
import numpy as np
from phonometry import room

# A 7 x 5 x 3 m room, source and receiver placed off-centre.
res = room.image_source_rir(
    dimensions=(7.0, 5.0, 3.0),
    source=(2.0, 1.6, 1.5),
    receiver=(5.2, 3.4, 1.7),
    absorption=0.12,            # uniform wall absorption
    fs=48000,
    max_order=12,
)

print(res.ir.shape)                          # (n_samples,) broadband RIR
print(round(res.direct_time * 1000, 2))      # direct-sound arrival, ms
print(res.times.size, room.audible_image_count(12) + 1)  # images + direct source

# Feed the synthetic RIR straight into the ISO 3382 decay analysis.
params = room.room_parameters(res.ir, res.fs, limits=None)
print(bool(params.t30_valid[0]))             # True: the decay window is usable
# T30 rises toward the Eyring estimate as max_order grows (see below); at a low
# order the specular tail is truncated, so treat this as an order-limited value.
print(round(float(params.t30[0]), 2))        # reverberation time, s
```

`image_source_rir` returns an `ImageSourceResult`. Its `ir` is the sampled
RIR (a 1D array broadband, or one row per octave band for per-band
absorption); the **exact** sub-sample reflection table is kept separately in
`times`, `distances`, `orders`, `amplitudes` and `image_positions`, so the
geometry stays exact regardless of the sample rate. `.plot()` draws the
reflectogram (reflection level in dB versus arrival time, coloured by order).

Pass per-band coefficients (a `(6, n_bands)` per-wall array, a per-band vector,
or a `frequencies` list) to synthesise one decay per octave band; pass a
length-6 vector to set each wall separately (order
`x0, xL, y0, yL, z0, zL`); and pass `air_attenuation` (the intensity
coefficient `m` from `air_attenuation_m`) to add the `exp(−m r / 2)` air loss.

![Image-source reflectogram: the synthetic room impulse response of a 7x5x3 m room as a cloud of reflections coloured by reflection order, decaying under the 1/r spreading envelope with the direct sound marked at order 0](https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/image_source_reflectogram.webp)

**Reproducing the statistical decay.** The fitted initial decay slope of the
reverberant energy density of the synthetic RIR recovers the **Eyring**
reverberation time `T = −24 V ln 10 / (c S ln(1 − ᾱ))` (Kuttruff Equation
(5.23)): the mean reflection rate `c S / 4 V` equals
`(c/2)(1/Lx + 1/Ly + 1/Lz)`, so the specular field's initial decay rate is the
one that defines that `T`. The match is exact only in the near-cubic
limit; an elongated room sustains energy along its long axis, so its pure
*specular* decay runs slower than Eyring's diffuse-field estimate (the regime
the [Fitzroy and Arau-Puchades models](reverberation-prediction.md) correct).
The model captures specular reflections only, with no diffraction or diffuse
scattering, and is exact only for real, angle-independent wall reflection
factors (Kuttruff 4.1).

## 2. Steady-state room field

When a source of constant sound power runs in a room, the sound pressure level
settles to the sum of a **direct field** that falls with distance and a
**reverberant field** that (to the diffuse approximation) is the same
everywhere. The **room constant**

```text
R = S ᾱ / (1 − ᾱ)                       (Bies Equation (6.44))
```

with total boundary area `S` and mean Sabine absorption `ᾱ` measures how much
reverberant field a given power builds up. The **steady-state level** is

```text
Lp = Lw + 10 log10( Q / (4 π r²) + 4 / R )   [ + 10 log10(ρc / 400) ]
                                               (Bies Equation (6.43))
```

with the source directivity factor `Q` (1 omnidirectional, 2 on a hard floor,
4 in an edge, 8 in a corner). The optional `10 log10(ρc / 400)` term
(about +0.14 dB at 20 °C) corrects for a characteristic impedance differing
from 400 Pa·s/m and is omitted by default. The **critical distance**

```text
rc = √( Q R / (16 π) )
```

is where the two fields are equal (the crossover of Equation (6.43)); closer
than `rc` the direct field dominates, farther the reverberant field does.
Kuttruff's reverberation distance (Equation (5.44), `rc = √(A / 16 π)` for
`Q = 1`) uses the Sabine absorption area `A = S ᾱ` instead of the room
constant `R = A / (1 − ᾱ)`; the two coincide for a small `ᾱ` and this module
uses `R`, so `rc` is exactly the crossover of its own `steady_state_spl`.

```python
from phonometry import room

# A 90 dB source in a 100 m^2 room with 20 % mean absorption.
field = room.steady_state_field(
    sound_power_level=90.0,
    surface_area=100.0,
    mean_absorption=0.2,
)
print(round(field.room_constant, 1))          # 25.0 m^2
print(round(field.critical_distance, 2))       # 0.71 m
field.plot()                                    # direct / reverberant / total vs distance

# The building blocks are exposed individually, too:
print(round(float(room.room_constant(100.0, 0.2)), 1))            # 25.0
print(round(float(room.critical_distance(25.0)), 3))              # 0.705
print(round(float(room.steady_state_spl(90.0, 5.0, 25.0)), 2))    # far-field level
```

The **Schroeder frequency**

```text
f_s = 2000 √(T / V)                       (Kuttruff Equation (3.44))
```

(`V` in m³, `T` in s) roughly marks the modal-to-diffuse transition, a
heuristic crossover rather than a sharp cutoff: well below it discrete room
modes dominate and the diffuse assumptions of `R` and `rc` grow unreliable,
well above it the modes overlap and the statistical field of this section
holds. In borderline rooms it is worth checking band by band.

```python
from phonometry import room
print(round(float(room.schroeder_frequency(1.0, 200.0)), 0))   # 141 Hz (V=200, T=1)
```

## Validation

The implementations are checked against the closed forms and the source texts'
own numeric anchors (see [CONFORMANCE.md](CONFORMANCE.md)):

- the direct-sound amplitude `1/(4 π r)` and delay `r / c` (exact geometry),
  the audible image count (Kuttruff Equation (9.23)) and the reflection
  density (Equation (4.6));
- the Eyring reverberation time recovered from the decay of the synthetic RIR
  in the near-cubic limit (documented ≈ 10 % tolerance), and an independent
  2D FDTD (`phonometry.simulation`) reproducing the rigid-wall echo delay and
  the uniform-damping `T60`;
- the room constant, the critical distance as the exact direct/reverberant
  crossover, the Schroeder frequency (Kuttruff's classroom example, `V = 200`,
  `T = 1` → 141 Hz) and the steady-state level (Bies Equation (6.43)).

## References

- Kuttruff, H. (2016). *Room acoustics* (6th ed.). CRC Press.
  [doi:10.1201/9781315372150](https://doi.org/10.1201/9781315372150).
  Section 4.1 (image sources, Equations (4.4)–(4.6)), Section 5.5–5.6
  (Eyring reverberation, reverberation distance, Equations (5.23), (5.44)),
  Section 3.6 (Schroeder frequency, Equation (3.44)) and Section 9.8 (audible
  image count, Equation (9.23)).
- Vorländer, M. (2020). *Auralization: Fundamentals of acoustics, modelling,
  simulation, algorithms and acoustic virtual reality* (2nd ed.). Springer.
  [doi:10.1007/978-3-030-51202-6](https://doi.org/10.1007/978-3-030-51202-6).
  Chapter 11 (the image-source / mirror-source model, Equations (11.36),
  (11.38), (11.39)).
- Allen, J. B., & Berkley, D. A. (1979). Image method for efficiently
  simulating small-room acoustics. *The Journal of the Acoustical Society of
  America*, 65(4), 943–950.
  [doi:10.1121/1.382599](https://doi.org/10.1121/1.382599).
  The reflection-count decomposition of the rectangular-room image lattice.
- Bies, D. A., Hansen, C. H., & Howard, C. Q. (2017). *Engineering noise
  control* (5th ed.). CRC Press.
  [doi:10.1201/9781351228152](https://doi.org/10.1201/9781351228152).
  Section 6.4 (steady-state response and the room constant, Equations
  (6.41)–(6.44)).

---


<!-- source: docs/insulation-field.md | canonical: https://jmrplens.github.io/phonometry/guides/insulation-field/ -->

# Field Insulation Measurement and Ratings

This guide continues from the [Room Acoustics guide](https://jmrplens.github.io/phonometry/guides/room-acoustics/):
the same impulse response, measured either side of a partition, yields its sound
insulation. This page covers insulation measured *in the building*: field
airborne, impact and façade insulation with single-number ratings
(ISO 16283-1/2/3, ISO 717-1/2), the measurement uncertainty that qualifies
every rating (ISO 12999-1) and the quick ISO 10052 survey method. The
laboratory characterisation of an element lives in
[Laboratory Insulation Measurement](https://jmrplens.github.io/phonometry/guides/insulation-lab/) and the prediction of
in-situ performance in
[Predicting Sound Insulation (EN 12354)](https://jmrplens.github.io/phonometry/guides/insulation-prediction/).

## Field insulation and single-number ratings (ISO 16283-1, ISO 717-1)

To rate a wall or floor, measure the energy-average level in the **source**
room ($L_1$) and the **receiving** room ($L_2$) per one-third-octave band
and form the level difference $D = L_1 - L_2$. Two normalisations make it
comparable between rooms. The **standardized level difference** references
the receiving-room reverberation time $T$ to $T_0 = 0.5$ s (so with
$T = 0.5$ s, $D_{nT} = D$ exactly), and the **apparent sound reduction
index** normalises by the partition area $S$ and the Sabine absorption area
$A$:

$$
D_{nT} = D + 10 \log_{10} \frac{T}{T_0}, \qquad
R' = D + 10 \log_{10} \frac{S}{A}, \qquad A = \frac{0.16\ V}{T}.
$$

Positions are energy-averaged with
$L = 10 \log_{10}\left( \frac{1}{n} \sum_i 10^{L_i/10} \right)$.

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/diagram_insulation_setup_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/diagram_insulation_setup.svg" alt="Field airborne insulation setup: a loudspeaker in the source room, microphones energy-averaged in source and receiving rooms across the common partition" width="92%"></picture>

The prime on $R'$ is a convention, not decoration: primed quantities ($R'$,
$L'_n$, $L'_{nT}$) are measured **in the building** and include every
flanking path, while the unprimed $R$ and $L_n$ are laboratory properties of
the element alone, measured with flanking suppressed. The full lab-to-field
map lives in [Laboratory Insulation Measurement](https://jmrplens.github.io/phonometry/guides/insulation-lab/); the
prediction that bridges the two is
[EN 12354](https://jmrplens.github.io/phonometry/guides/insulation-prediction/).

The band spectrum is collapsed to one number by the **reference-curve
method** of ISO 717-1: a fixed reference curve is shifted in 1 dB steps
toward the measured curve until the sum of *unfavourable* deviations
(where the measurement falls below the reference) is as large as possible
but not more than 32.0 dB (16 one-third-octave bands) or 10.0 dB (5 octave
bands). The rating (`Rw`, `R'w`, `DnT,w` …) is the shifted reference read at
500 Hz. The **spectrum adaptation terms** $C$ (pink noise) and $C_{tr}$
(urban traffic) add the low-frequency penalty of a real source.

The two terms re-rate the same measured curve against the two source spectra
of ISO 717-1 Annex A: $C$ against A-weighted pink noise, representative of
living activities (speech, music, radio, television), and $C_{tr}$ against
A-weighted urban road traffic, whose energy sits at low frequency. They are
defined so that the rating plus the term ($R_w + C$ for a laboratory index,
$R'_w + C$ or $D_{nT,w} + C$ for the field quantities of this guide, and
likewise with $C_{tr}$) is the A-weighted level difference achieved against
that source. Reading them:

* $C$ stays small for most constructions (0 to −2 dB is typical): the pink
  spectrum is close to the weighting already implicit in the reference
  curve.
* $C_{tr}$ punishes weak low-frequency insulation. A lightweight double leaf
  with its mass-air-mass resonance near 100 Hz can carry a $C_{tr}$ of −5 to
  −10 dB, while a heavy monolithic wall with the same $R_w$ loses far less:
  two constructions with equal ratings can differ audibly against traffic.
* Design with the descriptor that matches the noise, carried by the field
  quantity the requirement rates: $R'_w + C_{tr}$ for a façade on a busy
  road, $D_{nT,w} + C$ (or the plain rating, where the regulation says so)
  between dwellings, the two example requirements of ISO 717-1, 5.3.

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/insulation_rating_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/insulation_rating.svg" alt="Measured one-third-octave sound reduction index with the shifted ISO 717-1 reference curve and the resulting weighted rating at 500 Hz" width="80%"></picture>

```python
import numpy as np
from phonometry import building

# Energy-average several microphone positions in one room (dB)
print(round(float(building.energy_average_level([60.0, 66.0])), 1))   # 64.0

# Field insulation per band; area S and volume V add R'
l1 = np.full(16, 80.0)                                # source-room levels
l2 = np.full(16, 40.0)                                # receiving-room levels
t2 = np.full(16, 0.5)                                 # receiving-room T (s)
ins = building.airborne_insulation(l1, l2, t2, area=10.0, volume=50.0)
print(round(float(ins.dnt[0]), 1))                   # 40.0  (= D since T = T0)
print(round(float(ins.r_prime[0]), 1))               # 38.0

# Single-number rating from a measured 16-band R spectrum (ISO 717-1 Annex C)
R = [20.4, 16.3, 17.7, 22.6, 22.4, 22.7, 24.8, 26.6,
     28.0, 30.5, 31.8, 32.5, 33.4, 33.0, 31.0, 25.5]
w = building.weighted_rating(R)
print(w.rating, w.c, w.ctr)                          # 30 -2 -3  ->  Rw(C;Ctr) = 30(-2;-3)

w.plot()   # measured R' vs shifted ISO 717-1 reference, deviations shaded (needs matplotlib)
```

<details>
<summary>Show the code for this figure</summary>

```python
import matplotlib.pyplot as plt
from phonometry import building

# Single-number rating from a measured 16-band R spectrum (ISO 717-1 Annex C)
R = [20.4, 16.3, 17.7, 22.6, 22.4, 22.7, 24.8, 26.6,
     28.0, 30.5, 31.8, 32.5, 33.4, 33.0, 31.0, 25.5]
w = building.weighted_rating(R)

# One line — measured curve vs the shifted ISO 717-1 reference, deviations shaded:
w.plot()
plt.show()

# By hand, from the band curve the result now carries:
fig, ax = plt.subplots()
ax.semilogx(w.band_centers, w.measured, "o-", label="Measured R'")
ax.semilogx(w.band_centers, w.shifted_reference, "s--", label="Shifted reference")
ax.fill_between(w.band_centers, w.measured, w.shifted_reference,
                where=w.measured < w.shifted_reference, interpolate=True,
                alpha=0.3, label="Unfavourable deviations")
ax.set_xlabel("Frequency [Hz]")
ax.set_ylabel("Sound reduction index [dB]")
ax.set_title(f"Rw = {w.rating} dB  (C={w.c:+d}; Ctr={w.ctr:+d})")
ax.legend()
plt.show()
```

</details>

Compute `l1`, `l2` and `t2` on the same 16 one-third-octave bands from
100 Hz to 3150 Hz (obtain `t2` from
`room_parameters(ir, fs, limits=(100, 3150), fraction=3).t30`, for example) and
pass them to `airborne_insulation`. Feed that function's `dnt` (or `r_prime`)
spectrum to `weighted_rating`, so every band aligns index-by-index with the
ISO 717-1 reference curve.

### `airborne_insulation()` parameters

| Parameter | Type | Units | Range / default | Notes |
| :--- | :--- | :--- | :--- | :--- |
| `l1` | 1D or 2D array | dB | one/band, or `(positions, bands)` | Source-room levels (2D is energy-averaged) |
| `l2` | 1D or 2D array | dB | same band count | Receiving-room levels |
| `t2` | 1D array | s | > 0, one per band | Receiving-room reverberation time |
| `area` | float, optional | m² | > 0, with `volume` | Partition area `S` (enables `R'`) |
| `volume` | float, optional | m³ | > 0, with `area` | Receiving-room volume `V` |
| `t0` | float | s | default `0.5` | Reference reverberation time `T0` |

### `weighted_rating()` parameters

| Parameter | Type | Units | Range / default | Notes |
| :--- | :--- | :--- | :--- | :--- |
| `values_by_band` | 1D array | dB | 16 (thirds) or 5 (octaves) | Measured `R`, `R'`, `DnT` … per band |
| `bands` | str or `None` | — | `'third-octave'` / `'octave'` / `None` | `None` infers from the count |

`airborne_insulation()` returns an `AirborneInsulationResult` (`d`, `dnt`,
`r_prime` or `None`); `weighted_rating()` returns a `WeightedRatingResult`
(`rating`, `c`, `ctr`, `unfavourable_sum`, all integers except the sum).

### Enlarged frequency ranges and one-decimal ratings

When the measurement covers more than the core 100–3150 Hz bands, ISO 717-1
Annex B defines additional adaptation terms with the range as a subscript
($C_{50\text{–}3150}$, $C_{50\text{–}5000}$, $C_{100\text{–}5000}$ and the
$C_{tr}$ counterparts), computed with the Table B.1 spectra over the enlarged
range. `weighted_rating_extended` takes the band values *with their centre
frequencies* and returns the core rating plus every extended term the input
covers (the impact counterpart `weighted_impact_rating_extended` adds
$C_{I,50\text{–}2500}$). With `one_decimal=True` the reference curve shifts in
0.1 dB steps and all reductions keep one decimal: the variant ISO 717
prescribes "for the expression of uncertainty" and ISO 12999-1 Annex B
requires when stating the uncertainty of a single-number value.

```python
from phonometry import building

# Single-number rating from a measured 16-band R spectrum (ISO 717-1 Annex C)
R = [20.4, 16.3, 17.7, 22.6, 22.4, 22.7, 24.8, 26.6,
     28.0, 30.5, 31.8, 32.5, 33.4, 33.0, 31.0, 25.5]

freqs = [50, 63, 80, 100, 125, 160, 200, 250, 315, 400, 500,
         630, 800, 1000, 1250, 1600, 2000, 2500, 3150, 4000, 5000]
r_ext = [18.7, 19.2, 20.0, *R, 26.8, 29.2]     # ISO 717-1 Annex C, Table C.2
ext = building.weighted_rating_extended(r_ext, freqs)
print(ext.rating, ext.c, ext.ctr, ext.c_50_5000, ext.ctr_50_5000)
# 30 -2 -3 -2 -4   ->  Rw(C;Ctr;C50-5000;Ctr,50-5000) = 30(-2;-3;-2;-4)

one_dp = building.weighted_rating_extended(r_ext, freqs, one_decimal=True)
print(one_dp.rating)   # 30.0, the 0.1 dB-step rating for uncertainty statements
```

### Impact sound (ISO 16283-2, ISO 717-2)

Footstep noise is rated the other way round. Instead of how much a floor
*blocks*, impact insulation measures how much a standardized **tapping
machine** on the floor above puts into the room below, so a *higher* number
is *worse*. The energy-average impact sound pressure level $L_i$ in the
receiving room is normalised like the airborne case, but with a sign flip on
the reverberation term:

$$
L'_{nT} = L_i - 10 \log_{10} \frac{T}{T_0}, \qquad
L'_n = L_i + 10 \log_{10} \frac{A}{A_0}, \quad
A_0 = 10\ \text{m}^2,\ A = \frac{0.16\ V}{T}.
$$

The **standardized** impact level $L'_{nT}$ ($T_0 = 0.5$ s for dwellings)
needs only the receiving-room $T$, so with $T = 0.5$ s it equals $L_i$; the
**normalized** level $L'_n$ (referenced to a 10 m² absorption area) also needs
the receiving-room volume. Note the **minus** sign: more reverberation
*lowers* $L'_{nT}$, opposite to the airborne $D_{nT}$.

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/diagram_impact_setup_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/diagram_impact_setup.svg" alt="Field impact insulation setup: a standardized tapping machine on the floor of the source room above, microphones energy-averaged in the receiving room below, and the receiving-room reverberation time" width="92%"></picture>

The single-number rating (ISO 717-2) shifts the same style of reference curve,
but an **unfavourable deviation now occurs where the measurement *exceeds* the
reference** (impact noise is worse when higher), the sign opposite to
ISO 717-1. The rating (`Ln,w`, `L'n,w`, `L'nT,w`) is the shifted reference read
at 500 Hz; for octave bands it is then reduced by 5 dB. The spectrum
adaptation term $C_I = L_{n,\text{sum}} - 15 - L_{n,w}$ uses the energetic sum
over 100–2500 Hz (the first 15 thirds, excluding 3150 Hz) or 125–2000 Hz
(octaves).
For measurements extended down to 50 Hz,
`weighted_impact_rating_extended` additionally returns the enlarged-range
term $C_{I,50\text{–}2500}$ (A.2.1 NOTE), and with `one_decimal=True` the
0.1 dB-step rating used in uncertainty statements (it reproduces the printed
$L_{n,r,0,w} = 77.6$ dB and $C_{I,r,0} = -10.3$ dB of A.2.2).

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/impact_rating_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/impact_rating.svg" alt="Measured one-third-octave normalized impact sound pressure level with the shifted ISO 717-2 reference curve and the resulting weighted rating read at 500 Hz" width="80%"></picture>

```python
import numpy as np
from phonometry import building

# 16 one-third-octave impact levels Li (100 Hz - 3150 Hz), dB, from the
# ISO 717-2 Annex C worked example, and the receiving-room T per band.
li = np.array([62.1, 63.2, 63.5, 66.2, 68.5, 70.0, 71.7, 73.1,
               73.8, 73.5, 73.8, 73.3, 73.1, 73.0, 72.4, 71.2])
t2 = np.full(16, 0.5)

imp = building.impact_insulation(li, t2, volume=50.0)
print(round(float(imp.l_n_t[0]), 1))          # 62.1  (= Li since T = T0)
print(round(float(imp.l_n[0]), 1))            # 64.1  normalized to A0 = 10 m^2

# Weighted impact rating + spectrum adaptation term CI (ISO 717-2)
res_imp = building.weighted_impact_rating(imp.l_n_t)
print(res_imp.rating, res_imp.ci, res_imp.unfavourable_sum)   # 79 -11 28.0  ->  L'nT,w(CI)=79(-11)

# Octave-band data carry the extra -5 dB reduction (Clause 4.3.2)
octave = np.array([65.3, 64.5, 58.0, 55.8, 43.0])
print(building.weighted_impact_rating(octave).rating)  # 54

res_imp.plot()   # measured L'nT vs shifted ISO 717-2 reference, measured-above shaded (needs matplotlib)
```

<details>
<summary>Show the code for this figure</summary>

```python
import matplotlib.pyplot as plt
import numpy as np
from phonometry import building

# 16 one-third-octave impact levels Li (100 Hz - 3150 Hz), dB, from the
# ISO 717-2 Annex C worked example, and the receiving-room T per band.
li = np.array([62.1, 63.2, 63.5, 66.2, 68.5, 70.0, 71.7, 73.1,
               73.8, 73.5, 73.8, 73.3, 73.1, 73.0, 72.4, 71.2])
t2 = np.full(16, 0.5)
imp = building.impact_insulation(li, t2, volume=50.0)
# Weighted impact rating + spectrum adaptation term CI (ISO 717-2)
res_imp = building.weighted_impact_rating(imp.l_n_t)

# One line — measured L'nT vs the shifted ISO 717-2 reference (measured-above shaded):
res_imp.plot()
plt.show()

# By hand, from the band curve the result now carries (note the opposite sign:
# an unfavourable deviation is where the MEASURED level exceeds the reference).
# Here the input was l_n_t, so the rated quantity is the field level L'nT,w:
fig, ax = plt.subplots()
ax.semilogx(res_imp.band_centers, res_imp.measured, "o-", label="Measured L'nT")
ax.semilogx(res_imp.band_centers, res_imp.shifted_reference, "s--", label="Shifted reference")
ax.fill_between(res_imp.band_centers, res_imp.shifted_reference, res_imp.measured,
                where=res_imp.measured > res_imp.shifted_reference, interpolate=True,
                alpha=0.3, label="Unfavourable deviations")
ax.set_xlabel("Frequency [Hz]")
ax.set_ylabel("Impact sound pressure level [dB]")
ax.set_title(f"L'nT,w = {res_imp.rating} dB  (CI={res_imp.ci:+d})")
ax.legend()
plt.show()
```

</details>

Feed `impact_insulation`'s `l_n_t` (or `l_n`) straight into
`weighted_impact_rating`; the rating and `CI` reproduce the ISO 717-2 Annex C
values (thirds `L'nT,w = 79`, `CI = −11`; octave `54`, `CI = 0`).

#### `impact_insulation()` parameters

| Parameter | Type | Units | Range / default | Notes |
| :--- | :--- | :--- | :--- | :--- |
| `li` | 1D or 2D array | dB | one/band, or `(positions, bands)` | Energy-average impact SPL (2D is averaged over positions) |
| `t2` | 1D array | s | > 0, one per band | Receiving-room reverberation time |
| `volume` | float, optional | m³ | > 0 | Receiving-room `V` (enables `L'n`) |
| `t0` | float | s | default `0.5` | Reference reverberation time `T0` |

#### `weighted_impact_rating()` parameters

| Parameter | Type | Units | Range / default | Notes |
| :--- | :--- | :--- | :--- | :--- |
| `values_by_band` | 1D array | dB | 16 (thirds) or 5 (octaves) | Measured `Ln`, `L'n` or `L'nT` per band |
| `bands` | str or `None` | — | `'third-octave'` / `'octave'` / `None` | `None` infers from the count |

`impact_insulation()` returns an `ImpactInsulationResult` (`l_n_t`, `l_n` or
`None`); `weighted_impact_rating()` returns an `ImpactRatingResult` (`rating`,
`ci` integers, `unfavourable_sum` in dB).

### ISO 717 report (`.report()`)

Both rating results render a one-page PDF fiche laid out like an
accredited-laboratory test report through a `report(path)` method: a
standard-basis line (measurement standard plus the ISO 717 rating part), an
optional metadata header block, the one-third-octave table beside the
measured-versus-shifted-reference plot (the result's own `.plot()`), the boxed
single-number result, an optional verdict row and a footer with the fixed
disclaimer. `WeightedRatingResult.report()` labels the airborne ISO 717-1 fiche
(`Rw (C; Ctr)`, deviations where the reference is above the measurement);
`ImpactRatingResult.report()` labels the impact ISO 717-2 fiche (`Ln,w (CI)`,
deviations the opposite way). `SoundReductionResult.report()` is a convenience
that rates the predicted `R(f)` and writes its fiche in one call.

The report metadata is supplied as a `ReportMetadata` frozen dataclass (every
field optional; only the supplied fields are rendered, and the numeric fields
must be finite and positive). Passing `metadata=None` produces a lightweight
prediction fiche (body, result and disclaimer only). When
`metadata.requirement` is set, a verdict row is added: an airborne rating passes
when it is at or above the requirement, an impact rating when it is at or below
it (a lower impact level is better). Setting `verbose=True` swaps the two-column
`f | value` table for the ISO 717 Annex C columns (frequency, measured value,
shifted reference, unfavourable deviation).

Rendering needs reportlab, kept out of the runtime dependencies as the optional
`phonometry[report]` extra (`pip install phonometry[report]`); a missing
reportlab raises a clear `ImportError` with the install command, and the plot
still needs matplotlib (`phonometry[plot]`). Only `engine="reportlab"` is
supported; any other engine raises `ValueError`. The fiche renders in English by
default; pass `language="es"` for a Spanish fiche (translated fixed strings and
a comma decimal separator), e.g.
`building.weighted_rating(R).report("Rw_fiche_es.pdf", language="es")`.

```python
from phonometry import building, ReportMetadata

# Airborne rating from a measured 16-band R spectrum (ISO 717-1)
R = [20.4, 16.3, 17.7, 22.6, 22.4, 22.7, 24.8, 26.6,
     28.0, 30.5, 31.8, 32.5, 33.4, 33.0, 31.0, 25.5]
metadata = ReportMetadata(
    specimen="200 mm reinforced-concrete wall",
    client="Acoustic Test Client Ltd.",
    area=10.0, mass_per_area=460.0,
    source_volume=53.0, receiving_volume=51.0,
    temperature=21.5, relative_humidity=45.0, pressure=101.3,
    test_room="Transmission suite T1",
    measurement_standard="ISO 10140-2",
    test_date="2026-07-18",
    laboratory="Phonometry Reference Laboratory",
    operator="J. M. Requena-Plens",
    report_id="PHN-2026-0042",
    requirement=42.0,          # adds the PASS/FAIL verdict row
)
building.weighted_rating(R).report(
    "Rw_fiche.pdf", metadata=metadata
)                                                           # Rw (C; Ctr)

# Impact rating from a measured 16-band L'nT spectrum (ISO 717-2)
l_nt = [45.0, 47.0, 48.0, 49.0, 51.0, 52.0, 53.0, 54.0,
        55.0, 56.0, 57.0, 58.0, 55.0, 52.0, 49.0, 46.0]
building.weighted_impact_rating(l_nt).report("Lnw_fiche.pdf")  # Ln,w (CI)
```

Rendered examples of both fiches, regenerated with `make reports`, are kept in
the repository. Click either preview to open the PDF:

[![Airborne ISO 717-1 example report: metadata header, one-third-octave R table beside the measured-versus-shifted-reference plot, boxed Rw (C; Ctr) and a PASS verdict](https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/reports/iso717_airborne_example.webp)](https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/reports/iso717_airborne_example.pdf)

*Airborne rating fiche (`WeightedRatingResult.report`), Rw (C; Ctr).*

[![Impact ISO 717-2 example report: the same accredited layout for the normalized impact level Ln, boxed Ln,w (CI) and a FAIL verdict](https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/reports/iso717_impact_example.webp)](https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/reports/iso717_impact_example.pdf)

*Impact rating fiche (`ImpactRatingResult.report`), Ln,w (CI).*

#### Report metadata (`ReportMetadata`)

Every field is optional and only the supplied ones are rendered, so the same
object drives a full accredited fiche and a lightweight prediction fiche. The
numeric fields are validated on construction by physical range.

| Field | Type | Rendered as |
| --- | --- | --- |
| `specimen`, `client`, `mounted_by`, `manufacturer` | `str` | Header identity of the tested element and who it was tested for / mounted by |
| `area`, `mass_per_area` | `float > 0` | Sample area *S* (m²) and measured mass per unit area (kg/m²) |
| `source_volume`, `receiving_volume` | `float > 0` | Room volumes (m³) |
| `temperature`, `relative_humidity`, `pressure` | `float` | Single representative climate: air temperature (°C, any sign), relative humidity (0–100 %), ambient pressure (kPa, > 0) |
| `source_temperature`, `source_relative_humidity`, `receiving_temperature`, `receiving_relative_humidity` | `float` | Per-room climate when source and receiving rooms are reported separately (same ranges as above) |
| `test_room`, `mounting`, `measurement_standard`, `test_date` | `str` | Facility, mounting condition, the measurement standard (forms the standard-basis line) and the test date |
| `laboratory`, `operator`, `report_id`, `notes` | `str` | Footer: institute, operator signature line, report number and free-form remarks |
| `requirement` | `float > 0` | Target single number; adds the verdict row (airborne passes at or above it, impact at or below it) |

### ISO 16283 field test report (`.report()`)

The per-band field results write the test report of ISO 16283-1:2014 /
ISO 16283-2:2020 Clause 14 directly, laid out like the recommended results
forms (Annex B / Annex C) and the accredited field reports built on them.
`AirborneInsulationResult.report()` renders the standardized level difference
*DnT* fiche (Figure B.1) or, with `quantity="r_prime"`, the apparent sound
reduction index *R'* fiche (Figure B.2); `ImpactInsulationResult.report()`
renders the standardized *L'nT* fiche (Figure C.1) or, with `quantity="l_n"`,
the normalized *L'n* fiche (Figure C.2). Each fiche names the field standard
in its basis line, evaluates the ISO 717-1 / ISO 717-2 single-number rating
over the 16 core one-third-octave bands (100-3150 Hz), states the quantity to
one decimal place both in tabular form and as a curve against the shifted
reference curve (Clause 12), boxes the field rating (`DnT,w (C; Ctr)`,
`R'w (C; Ctr)`, `L'nT,w (CI)` or `L'n,w (CI)`) and prints the mandatory
statement that the evaluation is based on field measurement results obtained
by an engineering method.

`verbose=True` swaps the two-column table for the per-band measurement chain
(the energy-average *L1* and *L2*, or *Li*, and the reverberation time *T*
beside the reported quantity), the content accredited field reports annex; it
needs a result built by `airborne_insulation()` / `impact_insulation()`, which
retain those inputs on the result (`l1`, `l2`/`li`, `t2`, `t0`). Metadata, the
requirement verdict (airborne passes at or above it, impact at or below it),
`language="es"` and the `phonometry[report]` extra behave exactly as in the
ISO 717 fiche above.

```python
import numpy as np
from phonometry import building, ReportMetadata

# Field airborne: source/receiving levels and T per one-third-octave band
l1 = np.array([92.3, 93.1, 94.0, 94.4, 94.8, 95.0, 95.2, 95.4,
               95.3, 95.1, 94.8, 94.4, 93.9, 93.3, 92.5, 91.6])
l2 = l1 - np.array([38.2, 40.1, 42.6, 45.2, 47.8, 50.1, 52.3, 54.0,
                    55.6, 57.1, 58.2, 59.0, 59.6, 60.1, 60.3, 59.8])
t2 = np.array([0.62, 0.58, 0.55, 0.53, 0.52, 0.50, 0.49, 0.48,
               0.47, 0.46, 0.45, 0.45, 0.44, 0.43, 0.43, 0.42])
field = building.airborne_insulation(l1, l2, t2, area=12.5, volume=30.4)
metadata = ReportMetadata(
    specimen="Separating wall, 240 mm brick with independent lining",
    client="Example client",
    area=12.5, source_volume=32.1, receiving_volume=30.4,
    test_room="Dwelling A living room to dwelling B living room",
    test_date="2026-07-20",
    laboratory="Phonometry Reference Laboratory",
    report_id="PHN-2026-0143",
    requirement=50.0,               # DnT,w >= 50 dB -> PASS/FAIL row
)
field.report("DnTw_field.pdf", metadata=metadata)      # DnT,w (C; Ctr)
field.report("Rpw_field.pdf", quantity="r_prime",
             metadata=metadata)                        # R'w (C; Ctr)
field.report("DnTw_chain.pdf", metadata=metadata,
             verbose=True)                             # f | L1 | L2 | T | DnT

# Field impact: tapping-machine levels in the receiving room
li = np.array([58.0, 60.5, 62.0, 63.5, 65.0, 66.0, 66.5, 66.0,
               65.5, 65.0, 64.0, 62.0, 59.0, 56.0, 53.0, 50.0])
imp = building.impact_insulation(li, t2, volume=30.4)
imp.report("LnTw_field.pdf",
           metadata=ReportMetadata(requirement=58.0))  # L'nT,w (CI)
```

Rendered examples of both field fiches, regenerated with `make reports`, are
kept in the repository. Click either preview to open the PDF:

[![Field airborne ISO 16283-1 example report: metadata header, one-third-octave DnT table beside the measured-versus-shifted-reference curve, boxed DnT,w (C; Ctr), the engineering-method statement and a PASS verdict](https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/reports/iso16283_airborne_example.webp)](https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/reports/iso16283_airborne_example.pdf)

*Field airborne fiche (`AirborneInsulationResult.report`), DnT,w (C; Ctr).*

[![Field impact ISO 16283-2 example report: the same field layout for the standardized impact level L'nT with the 500 Hz read-off, boxed L'nT,w (CI), the engineering-method statement and a FAIL verdict](https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/reports/iso16283_impact_example.webp)](https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/reports/iso16283_impact_example.pdf)

*Field impact fiche (`ImpactInsulationResult.report`), L'nT,w (CI).*

### Field façade insulation (ISO 16283-3)

The same source/receiver logic reaches the building **façade**, but now the
source is *outdoors*: a loudspeaker at 45° or the road traffic itself. Rather
than a level difference across an internal partition, ISO 16283-3 references the
receiving-room level $L_2$ to the level **2 m in front of the façade**
$L_{1,2m}$, giving the level difference $D_{2m}$ and, exactly as in the airborne
case, its standardized and normalized forms:

$$
D_{2m} = L_{1,2m} - L_2, \quad
D_{2m,nT} = D_{2m} + 10 \log_{10}\frac{T}{T_0}, \quad
D_{2m,n} = D_{2m} - 10 \log_{10}\frac{A}{A_0},
$$

with $T_0 = 0.5$ s, $A_0 = 10$ m² and $A = 0.16\ V/T$ (dwellings). When the
microphone sits **on the test element** (surface level $L_{1,s}$) the *element*
method also yields an apparent sound reduction index, carrying a fixed
angle-of-incidence correction: $-1.5$ dB for the 45° loudspeaker method,
$-3$ dB for the all-angle road-traffic method:

$$
R'_{45°} = L_{1,s} - L_2 + 10 \log_{10}\frac{S}{A} - 1.5, \qquad
R'_{tr,s} = L_{1,s} - L_2 + 10 \log_{10}\frac{S}{A} - 3.
$$

The façade quantity is airborne, so its single-number rating uses the
**ISO 717-1** reference curve through `weighted_rating` unchanged (Annex F).

```python
import numpy as np
from phonometry import building

# Outdoor level 2 m in front of the façade, receiving-room level and T per
# one-third-octave band; surface_level is the microphone on the test element.
l1_2m = np.full(16, 75.0)                              # L1,2m outdoors
l2 = np.full(16, 33.0)                                 # receiving-room L2
t2 = np.full(16, 0.5)                                  # receiving-room T (s)

fac = building.facade_insulation(l1_2m, l2, t2, volume=50.0, area=11.5,
                        surface_level=np.full(16, 78.0), method="loudspeaker")
print(round(float(fac.d_2m[0]), 1))                    # 42.0  D2m = L1,2m - L2
print(round(float(fac.d_2m_nt[0]), 1))                 # 42.0  (= D2m since T = T0)
print(round(float(fac.d_2m_n[0]), 1))                  # 40.0  normalized to A0 = 10 m^2
print(round(float(fac.r_prime[0]), 1))                 # 42.1  R'45deg (loudspeaker, -1.5 dB)

# The road-traffic element method carries the -3 dB all-angle correction instead
tr = building.facade_insulation(l1_2m, l2, t2, volume=50.0, area=11.5,
                       surface_level=np.full(16, 78.0), method="road_traffic")
print(round(float(tr.r_prime[0]), 1))                  # 40.6  R'tr,s (traffic, -3 dB)

# The façade quantity is airborne: rate D2m,nT with the ISO 717-1 engine
print(building.weighted_rating(fac.d_2m_nt).rating)             # 42  Dls,2m,nT,w

fac.plot()   # per-band D2m,nT with D2m, D2m,n and R' overlaid (needs matplotlib)
```

`surface_level`, `area` and `volume` are all optional: with only `l1_2m`, `l2`
and `t2` the function returns `d_2m` and `d_2m_nt`; add `volume` for `d_2m_n`;
add `surface_level` **and** `area` **and** `volume` for `r_prime`. Positions are
energy-averaged with the surface-level formula (Clause 9.5.1); band levels are
assumed already corrected for background noise.

#### `facade_insulation()` parameters

| Parameter | Type | Units | Range / default | Notes |
| :--- | :--- | :--- | :--- | :--- |
| `l1_2m` | 1D or 2D array | dB | one/band, or `(positions, bands)` | Level 2 m in front of the façade `L1,2m` |
| `l2` | 1D or 2D array | dB | same band count | Receiving-room levels |
| `t2` | 1D array | s | > 0, one per band | Receiving-room reverberation time |
| `area` | float, optional | m² | > 0, with `surface_level`, `volume` | Test-element area `S` (enables `R'`) |
| `volume` | float, optional | m³ | > 0 | Receiving-room `V` (enables `D2m,n`; required for `R'`) |
| `surface_level` | 1D/2D array, optional | dB | same band count | Surface level `L1,s` on the element (enables `R'`) |
| `method` | str | — | `'loudspeaker'` (−1.5 dB) / `'road_traffic'` (−3 dB) | Angle-of-incidence correction of `R'` |
| `t0` | float | s | default `0.5` | Reference reverberation time `T0` |
| `frequencies` | 1D array, optional | Hz | — | Band centres carried on the result for plotting |

`facade_insulation()` returns a `FacadeInsulationResult` (`d_2m`, `d_2m_nt`,
`d_2m_n` or `None`, `r_prime` or `None`, `frequencies`); feed any 16-band façade
quantity to `weighted_rating` for its ISO 717-1 single number.

## Measurement uncertainty (ISO 12999-1)

A rating without an uncertainty is only half a result. ISO 12999-1 does not
re-measure anything; it tabulates the **standard uncertainty** $u$ of every
sound-insulation quantity, derived from inter-laboratory tests, and prescribes
how to expand and combine it. Which standard deviation is $u$ depends on the
**measurement situation** (Clause 5.2):

| Situation | Meaning | Standard uncertainty $u$ |
| :--- | :--- | :--- |
| **A** | laboratory characterisation (ISO 10140) | reproducibility $\sigma_R$ |
| **B** | same location, different teams | in-situ $\sigma_{situ}$ |
| **C** | same location, same operator repeated | repeatability $\sigma_r$ |

The expanded uncertainty is $U = k\ u$ (Formula 2) with the coverage factor $k$
of Table 8. A two-sided interval $Y = y \pm U$ (Formula 3, $k = 1.96$ at 95 %)
*reports* a value; the **one-sided** factor ($k = 1.65$ at 95 %) *declares
conformity* with a requirement (Formulae 4/5).

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/diagram_iso12999_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/diagram_iso12999.svg" alt="ISO 12999-1 uncertainty flow: standard uncertainty from the tables, reduced by repeated measurements and combined in quadrature, then expanded by the Table 8 coverage factor into a two-sided report or a one-sided conformity decision" width="82%"></picture>

```python
from phonometry import building

# Situation B (same building, different teams) -> the in-situ standard deviation.
print(building.single_number_uncertainty("r_w", "B"))       # 0.9  dB  (Table 3)
u = building.band_uncertainty("airborne", "B")              # per-band u (Table 2)
print(len(u.frequencies), u.uncertainties[10])     # 21 1.1  (the 500 Hz band)

# Report R'w = 52 dB with a two-sided 95 % interval (k = 1.96, Table 8):
uv = building.uncertain_value(52.0, "rprime_w", "B")        # aliases resolve to r_w
print(uv.coverage_factor, round(uv.expanded_uncertainty, 1))    # 1.96 1.8
print(round(uv.lower, 1), round(uv.upper, 1))      # 50.2 53.8  ->  52 ± 1.8 dB

# Declaring conformity uses the ONE-sided factor (k = 1.65): does R'w provably
# clear a 50 dB requirement?
uc = building.uncertain_value(52.0, "rprime_w", "B", one_sided=True)
print(building.satisfies_lower_requirement(52.0, uc.expanded_uncertainty, 50.0))   # True
```

Impact quantities offer situations B/C only (Table 4, no 500 Hz band in the 2020
edition), and $\Delta L$ only situation A. Descriptors are case-insensitive with
aliases (`rprime_w`/`dnt_w`→`r_w`, `lprime_n_w`→`ln_w`); combine independent
components in quadrature with `combine_uncertainties`, and reduce by $m$
independent measurements with `reduce_by_independent_measurements` ($u/\sqrt{m}$).

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/insulation_uncertainty_demo_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/insulation_uncertainty_demo.svg" alt="A weighted rating reported with its two-sided 95 % expanded uncertainty in situations A, B and C, the reproducibility uncertainty widest and the repeatability uncertainty narrowest" width="80%"></picture>

<details>
<summary>Show the code for this figure</summary>

```python
import matplotlib.pyplot as plt
from phonometry import building

# The same R'w = 52 dB reported in each situation with its two-sided 95 % U.
situations = ["A", "B", "C"]
vals = [building.uncertain_value(52.0, "r_w", s) for s in situations]

fig, ax = plt.subplots(figsize=(7, 4))
ax.errorbar(situations, [v.value for v in vals],
            yerr=[v.expanded_uncertainty for v in vals],
            fmt="o", capsize=8, color="tab:blue")
for s, v in zip(situations, vals):
    ax.annotate(f"±{v.expanded_uncertainty:.1f}", (s, v.upper),
                textcoords="offset points", xytext=(8, 4))
ax.set_ylabel("R'w [dB]"); ax.set_xlabel("Measurement situation")
ax.set_title("R'w = 52 dB with 95 % expanded uncertainty (ISO 12999-1)")
fig.tight_layout()
plt.show()
```

</details>

### `band_uncertainty()` / `single_number_uncertainty()` / `uncertain_value()` parameters

| Parameter | Type | Units | Range / default | Notes |
| :--- | :--- | :--- | :--- | :--- |
| `measurand` | str | — | `'airborne'` / `'impact'` / `'impact_reduction'` | Selects Table 2 / 4 / 6 |
| `quantity` | str | — | `'r_w'`, `'ln_w'`, `'delta_lw'` (+ aliases, `+c`/`+ctr` variants) | Single-number descriptor |
| `situation` | str | — | `'A'` / `'B'` / `'C'` | Measurement situation (Clause 5.2) |
| `value` | float | dB | — | Best estimate `y` to attach `U` to |
| `coverage` | float | — | default `0.95` | Confidence level (Table 8) |
| `one_sided` | bool | — | default `False` | One-sided factor for conformity checks |
| `upper_limit` | bool | — | default `False` | Select the σR95 upper limit (airborne, situation A) |

`band_uncertainty()` returns a `BandUncertainty` (`frequencies`,
`uncertainties`, `.to_arrays()`); `single_number_uncertainty()` a float;
`uncertain_value()` an `UncertainValue` (`value`, `standard_uncertainty`,
`coverage_factor`, `expanded_uncertainty`, `.lower`, `.upper`). The read-only
`COVERAGE_FACTORS` mapping exposes Table 8 keyed by `(confidence, one_sided)`.

## Field survey method (ISO 10052)

The engineering methods above buy accuracy with effort: swept microphones,
per-band reverberation times, careful background correction. For a quick check
in a dwelling, ISO 10052 defines a **survey (control) method**: octave bands, a
hand-held meter, and a single quantity, the **reverberation index**
`k = 10 lg(T/T0)` (`T0 = 0.5 s`), to carry the receiving-room correction. Every
survey quantity is then just an addition of `k`: the standardized level
difference `DnT = D + k`, the normalized `Dn = D + k + 10 lg(A0 T0 / (0.16 V))`,
the apparent `R' = D + k + 10 lg(S T0 / (0.16 V))` (using `V/7.5` for `S` where
that is larger), and, for impacts and façades, `L'nT = Li - k` and
`D2m,nT = D2m + k`. The clause references follow ISO 10052:2021; the formulas
and the reverberation-index table are identical in the harmonized
EN ISO 10052:2004+A1:2010.

The reverberation index is either **measured** (feed the reverberation time to
`reverberation_index(T)`) or, in a control survey, **estimated** from the room
type and volume with `estimate_reverberation_index(V, room)` (Table 4:
furnished `"kitchen"` / `"bathroom"` / `"furnished"`, or the unfurnished
construction classes `"a"`–`"h"` and the mixed `"a+e"`…`"d+h"`). A fourth
quantity unique to this method is **service-equipment noise** `LXY`: the
energy average of three A- or C-weighted positions.

```python
import numpy as np
from phonometry import building

# Octave-band levels (125-2000 Hz) and the measured receiving-room T.
l1 = np.array([88.0, 90.0, 92.0, 92.0, 90.0])
l2 = np.array([55.0, 51.0, 47.0, 41.0, 35.0])
k = building.reverberation_index([0.70, 0.60, 0.50, 0.45, 0.40])   # k = 10 lg(T/0.5)
res = building.survey_airborne_insulation(l1, l2, k, volume=50.0, area=12.0)
print(np.round(res.d_nt, 1))          # [34.5 39.8 45.  50.5 54. ]  DnT = D + k
print(res.rating.rating, res.rating.c)        # 49 -1  ->  DnT,w (C)
print(res.r_prime_rating.rating)               # 48  ->  R'w

# No reverberation time measured? Estimate k from Table 4 (heavy walls, hard
# floor, 35-60 m3 -> class "g").
k_est = building.estimate_reverberation_index(50.0, "g")
print(k_est)                                   # [4.5 5.  5.5 5.5 5.5]

# Service-equipment noise: energy average of three A-weighted positions.
se = building.survey_service_equipment_level([35.0, 30.0, 32.0], 3.0, volume=50.0)
print(round(float(se.l_xy), 1), round(float(se.l_xy_nt), 1))   # 32.8 29.8

res.plot()   # DnT vs shifted ISO 717-1 reference (needs matplotlib)
```

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/survey_insulation_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/survey_insulation.svg" alt="Survey-method airborne insulation: the raw level difference D and the standardized DnT across the five octave bands, with the reverberation-index correction k shaded between them" width="80%"></picture>

*The reverberation index `k = 10 lg(T/T0)` shifts the raw level difference `D`
into the standardized `DnT`: up where the room is live (`T > T0`), down where
it is dead. The automatic rating is formed only for exactly 5 octave (or 16
one-third-octave) values.*

<details>
<summary>Show the code for this figure</summary>

```python
import matplotlib.pyplot as plt
import numpy as np
from phonometry import building

# Octave-band levels (125-2000 Hz) and the measured receiving-room T.
bands = [125, 250, 500, 1000, 2000]
l1 = np.array([88.0, 90.0, 92.0, 92.0, 90.0])
l2 = np.array([55.0, 51.0, 47.0, 41.0, 35.0])
k = building.reverberation_index([0.70, 0.60, 0.50, 0.45, 0.40])   # k = 10 lg(T/0.5)
res = building.survey_airborne_insulation(l1, l2, k, volume=50.0)

x = np.arange(len(bands))
fig, ax = plt.subplots()
ax.fill_between(x, res.d, res.d_nt, alpha=0.2, label="k = 10 lg(T/T0)")
ax.plot(x, res.d, "--o", label="D (level difference)")
ax.plot(x, res.d_nt, "-s", label="DnT (standardized)")
ax.set_xticks(x, [str(b) for b in bands])
ax.set(xlabel="Frequency [Hz]", ylabel="Level difference [dB]",
       title=f"ISO 10052 survey method: DnT,w = {res.rating.rating} dB")
ax.legend()
plt.show()
```

</details>

### `survey_airborne_insulation()` and friends: parameters

| Parameter | Type | Units | Range / default | Notes |
| :--- | :--- | :--- | :--- | :--- |
| `l1` / `l2` | 1D or 2D array | dB | one/band, or `(positions, bands)` | Source / receiving (or outdoor `l1_2m`) levels |
| `li` | 1D or 2D array | dB | one/band, or `(positions, bands)` | Impact levels (energy-averaged over positions) |
| `reverberation_index` | scalar or 1D array | dB | one per band | `k` from `reverberation_index` or `estimate_reverberation_index`; `survey_service_equipment_level()` also accepts a scalar `k` |
| `volume` | float | m³ | > 0 | Receiving-room `V` (for `Dn` / `L'n` / `R'` / normalized) |
| `area` | float | m² | > 0 | Common-partition `S` (airborne `R'`; `V/7.5` rule applied) |
| `measurements` | array | dB | exactly 3 | Service-equipment positions (`survey_service_equipment_level`) |
| `room` | str | — | `"kitchen"`/`"bathroom"`/`"furnished"`/`"a"`–`"h"`/`"a+e"`… | `estimate_reverberation_index` room class (Table 4) |

`survey_airborne_insulation()` returns a `SurveyAirborneResult` (`d`, `d_nt`,
`d_n`, `r_prime`, `rating`, `r_prime_rating`); `survey_impact_insulation()` a
`SurveyImpactResult` (`l_i`, `l_nt`, `l_n`, `rating`);
`survey_facade_insulation()` a `SurveyFacadeResult`;
`survey_service_equipment_level()` a `SurveyServiceEquipmentResult` (`l_xy`,
`l_xy_nt`, `l_xy_n`).

## References

- Hopkins, C. (2007). *Sound insulation*. Butterworth-Heinemann.
  ISBN 978-0-7506-6526-1.
  [doi:10.4324/9780080550473](https://doi.org/10.4324/9780080550473).
  The comprehensive treatment of airborne and impact sound insulation:
  the measurement chains, the statistics of rooms behind the field
  quantities and the interpretation of the single-number ratings.
- Vigran, T. E. (2008). *Building acoustics*. CRC Press.
  ISBN 978-0-415-42853-8.
  [doi:10.1201/9781482266016](https://doi.org/10.1201/9781482266016).
  A compact textbook companion for the sound-transmission physics behind
  these measurements.
- International Organization for Standardization. (2020). *Acoustics —
  Rating of sound insulation in buildings and of building elements — Part 1:
  Airborne sound insulation* (ISO 717-1:2020).
  [iso.org catalogue](https://www.iso.org/standard/77435.html).
  The reference-curve rating and the spectrum adaptation terms interpreted
  above.
- International Organization for Standardization. (2014). *Acoustics — Field
  measurement of sound insulation in buildings and of building elements —
  Part 1: Airborne sound insulation* (ISO 16283-1:2014).
  [iso.org catalogue](https://www.iso.org/standard/55997.html).
  The field airborne method this page implements.

## Standards

ISO 16283-1:2014, ISO 16283-2 and ISO 16283-3:2016, *Acoustics —
Field measurement of sound insulation in buildings and of building elements*:
the level differences, normalisations and element methods; ISO 717-1 and
ISO 717-2, which give the reference-curve single-number ratings and the spectrum
adaptation terms C, Ctr and CI; ISO 12999-1:2020, which tabulates the standard
uncertainties per measurement situation and the coverage factors; its precision framework
builds on ISO 5725 (context, not implemented directly); ISO 10052:2021
(harmonized as EN ISO 10052:2004+A1:2010), which defines the field survey method: the
reverberation-index correction, the standardized/normalized airborne, impact
and façade quantities, and service-equipment noise.

## See also

- [Laboratory Insulation Measurement](https://jmrplens.github.io/phonometry/guides/insulation-lab/): the
  ISO 10140 element characterisation these field quantities are compared against.
- [Predicting Sound Insulation (EN 12354)](https://jmrplens.github.io/phonometry/guides/insulation-prediction/):
  the in-situ performance predicted from laboratory element data.
- [Room Acoustics](https://jmrplens.github.io/phonometry/guides/room-acoustics/): the impulse response,
  room parameters and sound absorption that this guide's insulation chain builds on.
- [Levels](https://jmrplens.github.io/phonometry/guides/levels/): energy averaging and the level metrics behind
  source/receiving-room levels.
- [Filter Banks](https://jmrplens.github.io/phonometry/guides/filter-banks/): the IEC 61260 fractional-octave filters
  used for the insulation spectra.
- [Theory](https://jmrplens.github.io/phonometry/reference/theory/rooms-buildings/): the reference-curve derivation behind the
  weighted single-number ratings.
- API reference: [`building.insulation`](https://jmrplens.github.io/phonometry/reference/api/building/insulation/), [`building.survey_insulation`](https://jmrplens.github.io/phonometry/reference/api/building/survey-insulation/) and [`building.building_uncertainty`](https://jmrplens.github.io/phonometry/reference/api/building/building-uncertainty/).

---


<!-- source: docs/insulation-lab.md | canonical: https://jmrplens.github.io/phonometry/guides/insulation-lab/ -->

# Laboratory Insulation Measurement

To rate a building element on its own (a wall type, a floating floor, a
window) you take it to a qualified laboratory, where suppressed flanking makes
the direct transmission the whole story. This page covers the laboratory
chain: the ISO 10140 sound reduction index and normalized impact level, the
sound-intensity alternative of ISO 15186, the small-mock-up floor-covering
improvement of ISO 16251-1 and the flanking-transmission measurement of
ISO 10848. Field measurement and ratings live in
[Field Insulation Measurement and Ratings](https://jmrplens.github.io/phonometry/guides/insulation-field/), and the
prediction that consumes these laboratory ratings in
[Predicting Sound Insulation (EN 12354)](https://jmrplens.github.io/phonometry/guides/insulation-prediction/).

## Laboratory measurement (ISO 10140)

An [ISO 16283 field measurement](https://jmrplens.github.io/phonometry/guides/insulation-field/) yields the primed
quantities ($R'$, $L'_n$): the number a real building achieves, flanking
transmission and all. To rate an
element on its own (a wall type, a floating floor, a window), you take it to a
qualified **laboratory** (ISO 10140), where suppressed flanking makes the
*direct* transmission the whole story. The formulas lose their primes: the
**sound reduction index** $R$ (not $R'$) and the **normalized impact level**
$L_n$ (not $L'_n$), with the receiving room's absorption area $A = 0.16\ V/T$
now a known property of the facility:

$$
R = L_1 - L_2 + 10 \log_{10}\frac{S}{A}, \qquad
L_n = L_i + 10 \log_{10}\frac{A}{A_0}, \quad A_0 = 10\ \text{m}^2.
$$

| | Field (ISO 16283) | Laboratory (ISO 10140) |
| :--- | :--- | :--- |
| Airborne element index | $R'$ apparent (with flanking) | $R$ direct (flanking suppressed) |
| Airborne room pair | $D_{nT}$, $D_n$ (no prime: room quantities) | — |
| Impact | $L'_n$, $L'_{nT}$ apparent | $L_n$ direct |
| Single number | $R'_w$, $D_{nT,w}$, $L'_{n,w}$, $L'_{nT,w}$ | $R_w$, $L_{n,w}$ |
| Absorption area | measured in the room | property of the facility |

The apostrophe is the flanking marker of building acoustics, and it travels
with the quantity into its single number: $R_w$ rates a laboratory spectrum,
$R'_w$ a field one. The standardized and normalized level differences
$D_{nT}$ and $D_n$ carry no prime because they describe the room pair rather
than an element, so there is no flanking-free counterpart to mark. In a
well-built construction $R'_w$ lands a few dB below the laboratory $R_w$ of
the same partition; a much larger gap says flanking dominates, and the
[EN 12354 model](https://jmrplens.github.io/phonometry/guides/insulation-prediction/) tells you which path carries it.

The single-number ratings reuse the very same ISO 717-1/2 engines
(`weighted_rating`, `weighted_impact_rating`): an $R$ spectrum rates to $R_w$
exactly as an $R'$ spectrum rated to $R'_w$. Before forming the index the
receiving-room levels must be **corrected for background noise** (Clause 4.3):
the energy subtraction $10 \log_{10}(10^{L_{sb}/10} - 10^{L_b/10})$ applies for a
6–15 dB signal-to-background margin, a fixed 1.3 dB correction (the *limit of
measurement*) at or below 6 dB, and no correction at or above 15 dB.

```python
import numpy as np
from phonometry import building

# Source/receiving levels and receiving-room T over the 16 one-third-octave
# bands; S is the free test-opening area, V the receiving-room volume.
l1 = np.full(16, 80.0)
l2 = np.full(16, 40.0)
t2 = np.full(16, 0.5)
lab = building.lab_airborne_insulation(l1, l2, t2, area=10.0, volume=50.0)
print(round(float(lab.r[0]), 1))              # 38.0  R = L1 - L2 + 10 lg(S/A)
print(round(float(lab.absorption[0]), 1))     # 16.0  A = 0.16 V / T (m^2)
print(lab.rating.rating, lab.rating.c, lab.rating.ctr)   # 38 0 0  ->  Rw(C;Ctr)

# Impact: the tapping-machine level Li normalized to A0 = 10 m^2 gives Ln
li = np.array([62.1, 63.2, 63.5, 66.2, 68.5, 70.0, 71.7, 73.1,
               73.8, 73.5, 73.8, 73.3, 73.1, 73.0, 72.4, 71.2])
imp = building.lab_impact_insulation(li, t2, volume=50.0)
print(round(float(imp.l_n[0]), 1))            # 64.1  Ln = Li + 10 lg(A/A0)
print(imp.rating.rating, imp.rating.ci)       # 81 -11  ->  Ln,w(CI)

# Background correction: margins 6 / 1 / 20 dB -> capped / capped / unchanged
corrected = building.background_correction([30.0, 33.0, 50.0], [24.0, 32.0, 30.0])
print(np.round(corrected, 1))                 # [28.7 31.7 50.0]  (1.3 dB cap twice)

lab.rating.plot()   # measured R vs shifted ISO 717-1 reference (needs matplotlib)
```

A margin at or below 6 dB emits a `LabInsulationWarning` and flags the band as
the limit of measurement; catch it with `warnings.simplefilter("error",
LabInsulationWarning)`. The automatic rating is formed only when exactly 16
one-third-octave or 5 octave values are supplied (`rating` is `None` otherwise).

### `lab_airborne_insulation()` / `lab_impact_insulation()` parameters

| Parameter | Type | Units | Range / default | Notes |
| :--- | :--- | :--- | :--- | :--- |
| `l1` / `l2` | 1D or 2D array | dB | one/band, or `(positions, bands)` | Source / receiving levels (airborne) |
| `li` | 1D or 2D array | dB | one/band, or `(positions, bands)` | Impact SPL from the tapping machine (impact) |
| `t2` | 1D array | s | > 0, one per band | Receiving-room reverberation time |
| `area` | float | m² | > 0 | Free test-opening area `S` (airborne only) |
| `volume` | float | m³ | > 0 | Receiving-room volume `V` |

`lab_airborne_insulation()` returns a `LabAirborneInsulationResult` (`r`,
`absorption`, `rating`); `lab_impact_insulation()` a
`LabImpactInsulationResult` (`l_n`, `absorption`, `rating`);
`background_correction(signal_and_background, background)` returns the corrected
levels directly.

### ISO 10140 laboratory test report (`.report()`)

Both laboratory results write the one-page ISO 10140 test report directly, laid
out like the accredited laboratory reports rated per ISO 717.
`LabAirborneInsulationResult.report()` renders the sound reduction index *R*
fiche (ISO 10140-2:2010) and `LabImpactInsulationResult.report()` the
normalized impact sound pressure level *Ln* fiche (ISO 10140-3:2010). Each
fiche names the laboratory standard in its basis line, evaluates the
ISO 717-1 / ISO 717-2 single-number rating (16 one-third-octave bands from
100 Hz to 3150 Hz, or the 5 octave bands), states the quantity to one decimal
place both in tabular form and as a curve against the shifted reference curve,
boxes the laboratory rating (`Rw (C; Ctr)` or `Ln,w (CI)`) and prints the
statement that the evaluation is based on laboratory measurement results
obtained by a precision method. Because a qualified suite suppresses flanking
transmission, the reported quantity is the *direct* *R* / *Ln*, not the field
*R'* / *L'n*.

`verbose=True` annexes the per-band equivalent sound absorption area
*A* = 0,16 *V* / *T* (ISO 10140-4:2010) beside the reported quantity, the
normalization datum the laboratory report carries. Metadata (client, specimen,
mounting, room volumes, climatic conditions), the requirement verdict (airborne
passes at or above it, impact at or below it), `language="es"` and the
`phonometry[report]` extra behave exactly as in the ISO 717 and ISO 16283
fiches.

```python
import numpy as np
from phonometry import building, ReportMetadata

# Laboratory airborne: source/receiving levels and T per one-third-octave band
l1 = np.full(16, 90.0)
r = np.array([20.4, 16.3, 17.7, 22.6, 22.4, 22.7, 24.8, 26.6,
              28.0, 30.5, 31.8, 32.5, 33.4, 33.0, 31.0, 25.5])
lab = building.lab_airborne_insulation(
    l1, l1 - r, np.full(16, 0.8), area=10.0, volume=50.0
)
metadata = ReportMetadata(
    specimen="100 mm autoclaved aerated concrete block wall",
    client="Example client",
    area=10.0, mass_per_area=75.0,
    source_volume=53.0, receiving_volume=50.0,
    test_room="Transmission suite (example)",
    mounting="Type A mounting, mortar-bedded perimeter (ISO 10140-1)",
    measurement_standard="ISO 10140-2",
    laboratory="Phonometry Reference Laboratory",
    report_id="PHN-2026-0143",
    requirement=30.0,               # Rw >= 30 dB -> PASS/FAIL row
)
lab.report("Rw_lab.pdf", metadata=metadata)            # Rw (C; Ctr)
lab.report("Rw_lab_chain.pdf", metadata=metadata,
           verbose=True)                               # f | A | R

# Laboratory impact: tapping-machine levels in the receiving room
li = np.array([62.1, 63.2, 63.5, 66.2, 68.5, 70.0, 71.7, 73.1,
               73.8, 73.5, 73.8, 73.3, 73.1, 73.0, 72.4, 71.2])
imp = building.lab_impact_insulation(li, np.full(16, 0.8), volume=50.0)
imp.report("Lnw_lab.pdf",
           metadata=ReportMetadata(requirement=80.0))  # Ln,w (CI)
```

Rendered examples of both laboratory fiches, regenerated with `make reports`,
are kept in the repository. Click either preview to open the PDF:

[![Laboratory airborne ISO 10140-2 example report: metadata header, one-third-octave R table beside the measured-versus-shifted-reference curve, boxed Rw (C; Ctr), the precision-method statement and a PASS verdict](https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/reports/iso10140_airborne_example.webp)](https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/reports/iso10140_airborne_example.pdf)

*Laboratory airborne fiche (`LabAirborneInsulationResult.report`), Rw (C; Ctr).*

[![Laboratory impact ISO 10140-3 example report: the same laboratory layout for the normalized impact level Ln with the 500 Hz read-off, boxed Ln,w (CI), the precision-method statement and a PASS verdict](https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/reports/iso10140_impact_example.webp)](https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/reports/iso10140_impact_example.pdf)

*Laboratory impact fiche (`LabImpactInsulationResult.report`), Ln,w (CI).*

## Sound insulation by intensity (ISO 15186)

The ISO 10140 laboratory method above reads the transmitted power *indirectly*, from the
receiving-room level and its absorption area; this breaks down when flanking
paths leak power the room integrates in anyway. The **sound-intensity** method
(ISO 15186) sidesteps that: an intensity probe scans a measurement surface that
encloses the specimen and measures the radiated power *directly*, so only the
element under test contributes. It is the tool of choice when flanking is high
(ISO 15186-1:2000, Clause 1). From the source-room level $L_{p1}$ and the
average normal intensity level $L_{In}$ over the surface (area $S_m$), for a
specimen of area $S$,

$$
R_I = L_{p1} - 6 - \left[ L_{In} + 10 \log_{10}\frac{S_m}{S} \right],
$$

where the $6$ dB is the diffuse-field offset between the sound pressure level
and the incident intensity level. The same formula gives the apparent index
$R'_I$ in the field (ISO 15186-2). Because the intensity method slightly
*underestimates* the power radiated into a real receiving room, a **modified
index** $R_{I,M} = R_I + K_c$ reproduces the ISO 10140-2 pressure result; the
adaptation term $K_c$ (Annex B) is $10 \log_{10}(1 + S_{b2}\lambda/8V_2)$ for a
well-defined room, or the room-independent $10 \log_{10}(1 + 61.4/f)$. For small
elements the **element normalized level difference** replaces $10\lg(S_m/S)$
with $10\lg(S_m/A_0) + 10\lg N$ ($A_0 = 10\ \text{m}^2$, $N$ element units).

```python
import numpy as np
from phonometry import building

# Source-room level Lp1 and the average normal intensity level LIn over the
# measurement surface (Sm), for a specimen of area S; 16 one-third-octave bands.
lp1 = np.full(16, 85.0)
l_in = np.full(16, 40.0)
freqs = [100, 125, 160, 200, 250, 315, 400, 500, 630, 800,
         1000, 1250, 1600, 2000, 2500, 3150]   # nominal 1/3-octave centres
kc = building.adaptation_term_kc(freqs)                  # Annex B (B.2)
res = building.intensity_sound_reduction(lp1, l_in, measurement_area=12.0, area=10.0, kc=kc)
print(round(float(res.r_i[0]), 2))          # 38.21  RI = Lp1 - 6 - [LIn + 10 lg(Sm/S)]
print(round(float(res.r_i_modified[0]), 2)) # 40.29  RI,M = RI + Kc
print(res.rating.rating)                     # 38  ->  RI,w (ISO 717-1 engine)

# Qualify the measurement surface: FpI = Lp - LIn must stay < 10 dB (< 6 dB when
# the receiving side is absorbing); the probe's residual index must exceed FpI+10.
fpi = building.surface_pressure_intensity_indicator(np.full(16, 46.0), l_in)
print(round(float(fpi[0]), 1))               # 6.0

res.plot()   # measured RI vs shifted ISO 717-1 reference (needs matplotlib)
```

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/intensity_insulation_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/intensity_insulation.svg" alt="Intensity sound reduction index RI and the Kc-modified index RI,M across the one-third-octave bands, with the Annex B adaptation lift shaded between the two curves" width="80%"></picture>

*The modified index $R_{I,M} = R_I + K_c$ lifts $R_I$ (most at the low bands,
where $K_c$ is largest), so an intensity measurement reproduces the ISO 10140-2
pressure result. The automatic rating is formed only for exactly 16
one-third-octave or 5 octave values (`rating`/`rating_modified` are `None`
otherwise). Subareas scanned separately are combined first with
`combine_subareas` (Formulas (11)-(12)); a subarea whose net energy flows back
towards the specimen enters with a negative area, applying the minus-sign rule
of Clause 6.4.6 while $S_m$ keeps the unsigned area sum.*

<details>
<summary>Show the code for this figure</summary>

```python
import matplotlib.pyplot as plt
import numpy as np
from phonometry import building

# A light wall: source-room SPL Lp1 = 85 dB and the measured normal intensity
# level LIn over the Sm = 12 m2 surface, 16 one-third-octave bands.
freqs = [100, 125, 160, 200, 250, 315, 400, 500, 630, 800,
         1000, 1250, 1600, 2000, 2500, 3150]
l_in = np.array([57.8, 61.9, 60.5, 55.6, 55.8, 55.5, 53.4, 51.6,
                 50.2, 47.7, 46.4, 45.7, 44.8, 45.2, 47.2, 52.7])
kc = building.adaptation_term_kc(freqs)           # Annex B adaptation term
res = building.intensity_sound_reduction(np.full(16, 85.0), l_in,
                                         measurement_area=12.0, area=10.0,
                                         kc=kc)

x = np.arange(len(freqs))
fig, ax = plt.subplots()
ax.fill_between(x, res.r_i, res.r_i_modified, alpha=0.2, label="Kc adaptation")
ax.plot(x, res.r_i, "-o", label="RI (intensity)")
ax.plot(x, res.r_i_modified, "--s", label="RI,M = RI + Kc")
ax.set_xticks(x, [str(f) for f in freqs], rotation=45)
ax.set(xlabel="Frequency [Hz]", ylabel="Sound reduction index [dB]",
       title=f"RI,w = {res.rating.rating} dB, RI,M,w = {res.rating_modified.rating} dB")
ax.legend()
plt.show()
```

</details>

### `intensity_sound_reduction()` / `adaptation_term_kc()` parameters

| Parameter | Type | Units | Range / default | Notes |
| :--- | :--- | :--- | :--- | :--- |
| `lp1` | 1D or 2D array | dB | one/band, or `(positions, bands)` | Source-room sound pressure level |
| `l_in` | 1D or 2D array | dB | one/band, or `(positions, bands)` | Normal intensity level over the surface |
| `measurement_area` | float | m² | > 0 | Measurement-surface area `Sm` |
| `area` | float | m² | > 0 | Specimen area `S` |
| `kc` | 1D array | dB | one per band / `None` | Adaptation term for the modified index |
| `freq` | 1D array | Hz | > 0 | Midband frequencies (`adaptation_term_kc`) |
| `boundary_area` / `volume` | float | m² / m³ | > 0, both or neither | Room `Sb2` / `V2` for Formula (B.1) |

`intensity_sound_reduction()` returns an `IntensityReductionResult` (`r_i`,
`r_i_modified`, `rating`, `rating_modified`);
`intensity_element_normalized_difference()` an
`IntensityElementNormalizedResult` (`d_i_n_e`, `rating`);
`surface_pressure_intensity_indicator()` and `combine_subareas()` return arrays.

### ISO 15186-1 intensity test report (`.report()`)

`IntensityReductionResult.report()` writes the one-page ISO 15186-1:2000 test
report of the intensity sound reduction index *R*<sub>I</sub>, reusing the same
accredited two-panel layout as the ISO 10140 and ISO 16283 fiches. Because
*R*<sub>I</sub> is an ordinary sound reduction index, its single-number rating
*R*<sub>I,w</sub> is the ISO 717-1 airborne rating evaluated on the intensity
spectrum: the fiche names ISO 15186-1 in its basis line, tabulates
*R*<sub>I</sub> to one decimal place beside the measured-versus-shifted-reference
curve, boxes `RI,w (C; Ctr)` and prints the statement that the transmitted
sound power was measured directly over the measurement surface. `verbose=True`
annexes the *K*<sub>c</sub>-modified index *R*<sub>I,M</sub> (Formula (9))
beside *R*<sub>I</sub> when an adaptation term was supplied.

The applicable `ReportMetadata` fields describe the intensity measurement:
`specimen` (the tested element), `area` (specimen area *S*), `client`,
`manufacturer`, `test_room`, `laboratory`, `operator`, `report_id` and
`test_date`, plus the room/climate fields shared with the other insulation
fiches. There is no dedicated field for the measurement-surface geometry or the
scanning-versus-discrete-point acquisition method; record those in `notes` and
name the standard in `measurement_standard` (`"ISO 15186-1"`). The requirement
verdict, `language="es"` and the `phonometry[report]` extra behave exactly as
in the sibling fiches.

```python
import numpy as np
from phonometry import building, ReportMetadata

freqs = np.array([100, 125, 160, 200, 250, 315, 400, 500, 630, 800,
                  1000, 1250, 1600, 2000, 2500, 3150], dtype=float)
lp1, sm, s = 85.0, 12.0, 10.0
l_in = np.array([57.8, 61.9, 60.5, 55.6, 55.8, 55.5, 53.4, 51.6,
                 50.2, 47.7, 46.4, 45.7, 44.8, 45.2, 47.2, 52.7])
kc = building.adaptation_term_kc(freqs)                 # Annex B, Formula (B.2)
res = building.intensity_sound_reduction(
    np.full(16, lp1), l_in, measurement_area=sm, area=s, kc=kc
)
metadata = ReportMetadata(
    specimen="100 mm autoclaved aerated concrete block wall",
    area=10.0, measurement_standard="ISO 15186-1",
    test_room="Transmission suite (example)",
    laboratory="Phonometry Reference Laboratory",
    report_id="PHN-2026-0150",
    requirement=30.0,                                   # RI,w >= 30 dB -> PASS
)
res.report("RIw.pdf", metadata=metadata)                # RI,w (C; Ctr)
res.report("RIw_kc.pdf", metadata=metadata, verbose=True)  # f | RI | RI,M
```

The example fiche is regenerated with `make reports` and kept in the
repository. Click the preview to open the PDF:

[![Intensity ISO 15186-1 example report: metadata header, one-third-octave RI table beside the measured-versus-shifted-reference curve, boxed RI,w (C; Ctr), the intensity-method statement and a PASS verdict](https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/reports/iso15186_intensity_example.webp)](https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/reports/iso15186_intensity_example.pdf)

*Intensity fiche (`IntensityReductionResult.report`), RI,w (C; Ctr).*

For a **small building element** (a ventilator, a socket, a small window) the
intensity method reports the element-normalized level difference
*D*<sub>I,n,e</sub> (Formula (8)) instead, normalized to the reference
absorption area *A*<sub>0</sub> = 10 m<sup>2</sup>.
`IntensityElementNormalizedResult.report()` writes the same one-page fiche
through the shared renderer, boxing `DI,n,e,w (C; Ctr)` rated per ISO 717-1;
`verbose=True` shows the ISO 717 evaluation per band and a `requirement` adds a
PASS/FAIL verdict (the element insulation passes at or above the target).

```python
import numpy as np
from phonometry import building, ReportMetadata

lp1, sm, n = 85.0, 12.0, 1                              # source SPL, surface, units
l_in = np.array([57.9, 62.0, 60.6, 55.7, 55.9, 55.6, 53.5, 51.7,
                 50.3, 47.8, 46.5, 45.8, 44.9, 45.3, 47.3, 52.8])
res = building.intensity_element_normalized_difference(
    np.full(16, lp1), l_in, measurement_area=sm, n=n
)
metadata = ReportMetadata(
    specimen="Trickle ventilator in a 100 mm masonry wall",
    measurement_standard="ISO 15186-1",
    laboratory="Phonometry Reference Laboratory",
    report_id="PHN-2026-0151",
    requirement=30.0,                                   # DI,n,e,w >= 30 dB -> PASS
)
res.report("DIne.pdf", metadata=metadata)               # DI,n,e,w (C; Ctr)
```

[![Intensity ISO 15186-1 element example report: metadata header, one-third-octave DI,n,e table beside the measured-versus-shifted-reference curve, boxed DI,n,e,w (C; Ctr), the intensity-method statement and a PASS verdict](https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/reports/iso15186_element_example.webp)](https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/reports/iso15186_element_example.pdf)

*Element intensity fiche (`IntensityElementNormalizedResult.report`), DI,n,e,w (C; Ctr).*

## Floor-covering impact improvement (ISO 16251-1)

ISO 16251-1:2014 is a laboratory method for the **improvement of impact sound
insulation** $\Delta L$ of a soft, locally-reacting floor covering (carpet, PVC,
linoleum). The two ISO 10140 rooms are replaced by a small softly-supported
concrete plate; a standard tapping machine excites it and the structure-borne
**acceleration level** on the underside is measured with and without the
covering. For locally-reacting coverings that acceleration-level difference
equals the ISO 10140 impact sound reduction.

**Acceleration level (Formula (1)).** $L_a = 10\lg(\langle a^2\rangle / a_0^2)$ dB,
reference $a_0 = 10^{-6}\ \text{m/s}^2$. **Background correction (Formula (2))**
follows the ISO 10140 three-branch rule (unchanged ≥ 15 dB; energy subtraction
for 6 ≤ margin < 15 dB; the 1.3 dB limit below 6 dB, flagged as $> \Delta L$).
The improvement is the position-averaged difference
$\Delta L = L_0 - L_1$ (Formulae (3)/(4)); octaves follow
$\Delta L_\text{oct} = -10\lg[\tfrac{1}{3}\sum 10^{-\Delta L_n/10}]$ (Formula (5)).

**Weighted improvement.** $\Delta L_w$ is the ISO 717-2 weighted reduction: the
improvement is applied to the heavyweight **reference floor** $L_{n,r,0}$
(ISO 717-2 Table 4), $L_{n,r} = L_{n,r,0} - \Delta L$, and
$\Delta L_w = 78 - L_{n,r,w}$, computed by `weighted_impact_improvement()`, which
reuses the verified ISO 717-2 rating engine. A clause 6.3 measurement spans 18
bands (100–5000 Hz, optionally extended to 50 Hz); the rating is formed on the
100–3150 Hz sub-range of whatever spectrum contains it. The statement of
results (clause 8 e)) also carries the spectrum adaptation term
$C_{I,\Delta} = C_{I,r,0} - C_{I,r}$ (ISO 717-2:2020 Formula (A.4)), exposed as
`ci_delta` on the result and standalone as
`impact_improvement_adaptation_term()`.

The worked example below is a real measurement: the improvement of a textile
carpet on the CSTB heavyweight mock-up, digitized from Figure 4 of Foret,
Chéné and Guigou-Carter, "A comparison of the reduction of transmitted impact
noise by floor coverings measured using ISO 140-8 and ISO/CD 16251-1" (Forum
Acusticum 2011, Aalborg). Its published ISO 16251-1 weighted improvement is
$\Delta L_w = 29$ dB, reproduced exactly by the rating engine.

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/floor_covering_improvement_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/floor_covering_improvement.svg" alt="ISO 16251-1 floor-covering impact sound improvement: the improvement delta-L of a real textile carpet rising with frequency across one-third-octave bands from 100 Hz to 3150 Hz, with the shaded improvement area and the weighted single-number delta-Lw annotated" width="80%"></picture>

<details>
<summary>Show the code for this figure</summary>

```python
import matplotlib.pyplot as plt
import numpy as np
from phonometry import building

freqs = [100, 125, 160, 200, 250, 315, 400, 500,
         630, 800, 1000, 1250, 1600, 2000, 2500, 3150]
bare = np.full(16, 78.0)                       # bare-plate acceleration level
# A real textile carpet measured on the CSTB mock-up (Foret et al. 2011, Fig. 4).
covering = bare - np.array([5, 8, 10, 14, 18, 23, 30, 31,
                            39, 49, 53, 57, 60, 67, 68, 71])
res = building.impact_improvement(bare, covering, freqs)
print(res.delta_lw)   # weighted improvement delta-Lw = 29 dB (ISO 717-2)
res.plot()
plt.show()
```

</details>

```python
from phonometry import building

# delta-Lw straight from an improvement spectrum (16 one-third-octave bands):
delta_l = [5, 8, 10, 14, 18, 23, 30, 31, 39, 49, 53, 57, 60, 67, 68, 71]
print(building.weighted_impact_improvement(delta_l))    # 29 dB (carpet)

# From the measured bare/covered acceleration levels, with a background trace:
freqs = [100, 125, 160, 200, 250, 315, 400, 500, 630, 800,
         1000, 1250, 1600, 2000, 2500, 3150]
bare_levels = [72, 73, 74, 74, 75, 75, 76, 76, 77, 77, 78, 78, 79, 79, 80, 80]
covered_levels = [b - d for b, d in zip(bare_levels, delta_l)]
bg = [40.0] * 16
res = building.impact_improvement(bare_levels, covered_levels, freqs, background=bg)
res.improvement       # delta-L per band
res.delta_lw          # weighted single number (rated on the 100-3150 Hz sub-range)
res.ci_delta          # spectrum adaptation term CI,delta (Formula (A.4))
res.limited           # bands at the 1.3 dB limit of measurement (> delta-L)
res.octave_bands()    # (octave freqs, delta-L_oct) via Formula (5)
```

### ISO 16251-1 impact-improvement report (`.report()`)

`FloorCoveringImprovementResult.report()` writes a one-page accredited
impact-improvement fiche: the ISO 16251-1 basis line, a metadata header, the
per-band table (frequency and *delta-L*, bands at the 1.3 dB limit prefixed
`>`) beside the *delta-L(f)* improvement curve, the boxed single-number
*delta-Lw (CI,delta)* (the ISO 16251-1 Clause 8 e) statement of results, rated
per ISO 717-2) and a footer. The applicable `ReportMetadata` fields are
`specimen` (the floor covering under test), `client`, `manufacturer`,
`mounting`, `mass_per_area`, `test_room`, `test_date`, `temperature`,
`pressure`, `measurement_standard`, `laboratory`, `operator`, `report_id`,
`notes` and `requirement` (a higher weighted improvement is better, so the
verdict passes at or above it). The bare reference floor is the standardised
heavyweight floor of ISO 717-2:2020 Table 4, fixed by the standard.
`verbose=True` adds the reference-floor-with-covering column
*Ln,r = Ln,r,0 - delta-L*, the derivation basis of *delta-Lw*.

```python
from phonometry import building, ReportMetadata

freqs = [100, 125, 160, 200, 250, 315, 400, 500,
         630, 800, 1000, 1250, 1600, 2000, 2500, 3150]
delta_l = [5, 8, 10, 14, 18, 23, 30, 31, 39, 49, 53, 57, 60, 67, 68, 71]
bare = [78.0] * 16
res = building.impact_improvement(bare, [b - d for b, d in zip(bare, delta_l)], freqs)
res.report("dLw.pdf",
           metadata=ReportMetadata(
               specimen="Textile floor covering (carpet), laid loose",
               measurement_standard="ISO 16251-1",
               requirement=20.0))  # delta-Lw (CI,delta) = 29 (-13) dB
```

A rendered example fiche, regenerated with `make reports`, is kept in the
repository. Click the preview to open the PDF:

[![Floor-covering impact improvement ISO 16251-1 example report: metadata header, one-third-octave delta-L table beside the delta-L(f) improvement curve, boxed delta-Lw (CI,delta) = 29 (-13) dB weighted improvement (ISO 717-2) and a PASS verdict against the 20 dB requirement](https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/reports/iso16251_floor_covering_example.webp)](https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/reports/iso16251_floor_covering_example.pdf)

## Laboratory flanking transmission (ISO 10848)

ISO 10848:2006/2010 is the laboratory method that **measures** the junction
**vibration reduction index** $K_{ij}$ that the [EN 12354 prediction](https://jmrplens.github.io/phonometry/guides/insulation-prediction/) takes
as an input, together with the overall flanking descriptors $D_{n,f}$
(airborne) and $L_{n,f}$ (impact). It is the measurement counterpart of the
empirical `junction_vibration_reduction()` of that prediction.

**Vibration reduction index (Formula (13)).**
$K_{ij} = \overline{D}_{v,ij} + 10\lg\!\big(l_{ij} / \sqrt{a_i a_j}\big)$ dB, from
the direction-averaged velocity level difference
$\overline{D}_{v,ij} = \tfrac{1}{2}(D_{v,ij} + D_{v,ji})$ (Formula (11), which
makes $K_{ij}$ symmetric), the common-edge junction length $l_{ij}$ and the
**equivalent absorption lengths** $a_j = 2.2\pi^2 S_j /(T_{s,j} c_0)\sqrt{f_\text{ref}/f}$
(Formula (12), $f_\text{ref} = 1000$ Hz). For lightweight well-damped elements
$a_j = S_j / l_0$ ($l_0 = 1$ m) and Formula (13) reduces to the simplified
Formula (14). The related **total loss factor** is $\eta = 2.2/(f T_s)$.

**Overall descriptors.** $D_{n,f} = L_1 - L_2 - 10\lg(A/A_0)$ (Formula (4),
airborne) and $L_{n,f} = L_2 + 10\lg(A/A_0)$ (Formula (5), tapping machine),
$A_0 = 10\ \text{m}^2$; their $D_{n,f,w}$ / $L_{n,f,w}$ single numbers reuse the
ISO 717 rating engines. The single-number $\overline{K}_{ij}$ is the arithmetic
mean over 200–1250 Hz for one-third-octave bands, or over 125–1000 Hz for
octave bands (Annex A).

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/flanking_transmission_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/flanking_transmission.svg" alt="ISO 10848 junction vibration reduction index Kij rising across one-third-octave bands from 100 Hz to 5000 Hz for a rigid T-junction of two heavy walls, with the single-number mean Kij over 200-1250 Hz drawn as a dashed line" width="80%"></picture>

<details>
<summary>Show the code for this figure</summary>

```python
import matplotlib.pyplot as plt
import numpy as np
from phonometry import building

freqs = [100, 125, 160, 200, 250, 315, 400, 500, 630,
         800, 1000, 1250, 1600, 2000, 2500, 3150, 4000, 5000]
# Direction-averaged velocity level difference of a rigid T-junction (dB):
dv = np.array([4.5, 4.8, 5.2, 5.6, 6.0, 6.5, 7.0, 7.6, 8.1, 8.7,
               9.2, 9.8, 10.3, 10.9, 11.4, 11.9, 12.3, 12.7])
res = building.vibration_reduction_index(
    dv, junction_length=4.0, area_i=12.0, area_j=10.0, frequency=freqs,
    structural_reverberation_time_i=0.35, structural_reverberation_time_j=0.40,
)
print(res.single_number)   # mean Kij over 200-1250 Hz (Annex A)
res.plot()
plt.show()
```

</details>

**Validity.** $K_{ij}$ rests on a statistical-energy-analysis simplification:
`strong_coupling_satisfied()` checks the Formula (15) inequality and, for the
heavy junctions of Part 4, `modal_density()`, `band_mode_count()` and
`modal_overlap_factor()` (Formulae (5)/(4)/(6)) quantify where the mode count
is too low for $K_{ij}$ to be reliable. Pass the per-band modal overlap factor
to `vibration_reduction_index(..., modal_overlap=M)`: bands with $M < 0.25$
are flagged in `result.bracketed` and excluded from the single-number
$\overline{K}_{ij}$, as Part 4 Clause 9 requires. Because ISO 10848 contains no
worked numeric example, conformance is anchored on closed-form identities
(simplified $K_{ij}$, $a_j$ at $f_\text{ref}$, $\eta$).

```python
import numpy as np
from phonometry import building

freqs = [200, 250, 315, 400, 500, 630, 800, 1000, 1250]
lij, s_i, s_j = 4.0, 12.0, 10.0     # junction length (m), element areas (m^2)
ts = np.linspace(0.30, 0.10, 9)     # structural reverberation time Ts (s)
dv_ij = [5.6, 6.0, 6.5, 7.0, 7.6, 8.1, 8.7, 9.2, 9.8]    # element i excited (dB)
dv_ji = [6.4, 6.8, 7.3, 7.8, 8.4, 8.9, 9.5, 10.0, 10.6]  # element j excited (dB)

# Kij from both excitation directions (symmetric via the direction average):
dbar = building.direction_averaged_level_difference(dv_ij, dv_ji)
res = building.vibration_reduction_index(dbar, lij, s_i, s_j, frequency=freqs,
                                structural_reverberation_time_i=ts,
                                structural_reverberation_time_j=ts)
res.k_ij           # Kij per band (Formula (13))
res.single_number  # mean Kij over 200-1250 Hz, or None without the band set
res.octave_bands() # Kij in octave bands (its single number averages 125-1000 Hz)

# Overall airborne flanking descriptor and a Part-4 modal-overlap validity check:
dnf = building.normalized_flanking_level_difference(np.full(9, 75.0), np.full(9, 42.0),
                                           absorption_area=np.full(9, 12.0))
m = building.modal_overlap_factor(s_i, critical_frequency=85.0,
                         structural_reverberation_time=ts)
res_m = building.vibration_reduction_index(dbar, lij, s_i, s_j, frequency=freqs,
                                  modal_overlap=m)   # M < 0.25 bands bracketed
res_m.bracketed    # per-band flags; bracketed bands leave the single number
```

### ISO 10848 flanking-transmission reports (`.report()`)

Each of the three results renders a one-page PDF fiche. `VibrationReductionResult.report()`
writes a **junction characterization** report of *K*<sub>ij</sub>
(ISO 10848-1:2006): the standard-basis line, an optional metadata header, the
per-band *K*<sub>ij</sub> table beside the *K*<sub>ij</sub>(*f*) curve and a
boxed single-number mean *K*<sub>ij</sub> over the Annex A band range, with the
count of averaged and bracketed bands. Bands bracketed for poor modal overlap
(*M* < 0.25, ISO 10848-4:2010 Clause 9) print their value in brackets and are
excluded from the mean; `verbose=True` adds a column stating whether each band
enters the mean.

`FlankingLevelDifferenceResult.report()` and `FlankingImpactLevelResult.report()`
write **measurement** reports of the overall descriptors *D*<sub>n,f</sub>
(airborne) and *L*<sub>n,f</sub> (impact, tapping machine), reusing the same
two-panel insulation layout: the per-band quantity beside the
measured-versus-shifted-ISO 717-reference curve and the boxed single number
`Dn,f,w (C; Ctr)` (ISO 717-1) or `Ln,f,w (CI)` (ISO 717-2). `verbose=True`
annexes the ISO 717 evaluation per band (the value, the shifted reference and
the unfavourable deviation). A `requirement` supplied on the `ReportMetadata`
adds a verdict (`Dn,f,w` passes at or above it, `Ln,f,w` at or below it), and
`language="es"` renders every fiche in Spanish. reportlab is required
(`pip install phonometry[report]`).

```python
import numpy as np
from phonometry import building, ReportMetadata

freqs = [100, 125, 160, 200, 250, 315, 400, 500, 630,
         800, 1000, 1250, 1600, 2000, 2500, 3150, 4000, 5000]
dv = np.array([4.5, 4.8, 5.2, 5.6, 6.0, 6.5, 7.0, 7.6, 8.1, 8.7,
               9.2, 9.8, 10.3, 10.9, 11.4, 11.9, 12.3, 12.7])
m = np.full(18, 1.0); m[:3] = 0.1                        # bracket the low bands
kij = building.vibration_reduction_index(
    dv, junction_length=4.0, area_i=12.0, area_j=10.0, frequency=freqs,
    structural_reverberation_time_i=0.35, structural_reverberation_time_j=0.40,
    modal_overlap=m,
)
kij.report("Kij.pdf", metadata=ReportMetadata(specimen="Rigid T-junction"))

l1 = np.full(16, 80.0)
dnf = np.array([48, 49, 50, 51, 52, 54, 55, 57, 58, 59, 60, 61, 62, 63, 64, 65],
               dtype=float)
dres = building.normalized_flanking_level_difference(
    l1, l1 - dnf, absorption_area=np.full(16, 10.0)
)
dres.report("Dnf.pdf", metadata=ReportMetadata(requirement=55.0))   # Dn,f,w (C; Ctr)

recv = np.array([58, 57, 56, 55, 54, 52, 50, 48, 46, 44, 42, 40, 38, 36, 34, 32],
                dtype=float)
lres = building.normalized_flanking_impact_level(recv, absorption_area=np.full(16, 10.0))
lres.report("Lnf.pdf", metadata=ReportMetadata(requirement=55.0))   # Ln,f,w (CI)
```

The example fiches are regenerated with `make reports` and kept in the
repository. Click a preview to open the PDF:

[![ISO 10848-1 junction report: metadata header, per-band Kij table beside the Kij curve, boxed single-number mean Kij with the count of averaged and bracketed bands](https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/reports/iso10848_kij_example.webp)](https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/reports/iso10848_kij_example.pdf)

*Vibration reduction index fiche (`VibrationReductionResult.report`), mean Kij.*

[![ISO 10848-2 airborne flanking report: per-band Dn,f table beside the measured-versus-shifted-reference curve, boxed Dn,f,w (C; Ctr) and a PASS verdict](https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/reports/iso10848_dnf_example.webp)](https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/reports/iso10848_dnf_example.pdf)

*Flanking level difference fiche (`FlankingLevelDifferenceResult.report`), Dn,f,w (C; Ctr).*

[![ISO 10848-2 impact flanking report: per-band Ln,f table beside the measured-versus-shifted-reference curve, boxed Ln,f,w (CI) and a PASS verdict](https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/reports/iso10848_lnf_example.webp)](https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/reports/iso10848_lnf_example.pdf)

*Flanking impact level fiche (`FlankingImpactLevelResult.report`), Ln,f,w (CI).*

## References

- Hopkins, C. (2007). *Sound insulation*. Butterworth-Heinemann.
  ISBN 978-0-7506-6526-1.
  [doi:10.4324/9780080550473](https://doi.org/10.4324/9780080550473).
  The reference monograph for laboratory sound-insulation measurement,
  flanking transmission and the vibration reduction index.
- Vigran, T. E. (2008). *Building acoustics*. CRC Press.
  ISBN 978-0-415-42853-8.
  [doi:10.1201/9781482266016](https://doi.org/10.1201/9781482266016).
  The transmission theory of single and double constructions that laboratory
  indices quantify.

## Standards

ISO 10140-2:2010, ISO 10140-3:2010 and ISO 10140-4:2010, which provide the
laboratory R and Ln with the background-noise correction; ISO 15186-1:2000 and
ISO 15186-2:2003, which define the sound-intensity sound reduction index $R_I$, its
$K_c$-modified form and the element normalized level difference (laboratory
and field); ISO 16251-1:2014, which specifies the small-mock-up laboratory method for the
impact-sound improvement $\Delta L$ of floor coverings, with $\Delta L_w$ via
the ISO 717-2 reference floor; ISO 10848-1:2006, ISO 10848-2:2006,
ISO 10848-3:2006 and ISO 10848-4:2010, which cover the laboratory measurement of flanking
transmission: the vibration reduction index $K_{ij}$, the equivalent
absorption length, the normalized flanking descriptors $D_{n,f}$ / $L_{n,f}$
and the modal-overlap validity checks that feed the EN 12354 prediction.

## See also

- [Field Insulation Measurement and Ratings](https://jmrplens.github.io/phonometry/guides/insulation-field/):
  the in-building airborne, impact and façade measurements, their single-number
  ratings and their uncertainty.
- [Predicting Sound Insulation (EN 12354)](https://jmrplens.github.io/phonometry/guides/insulation-prediction/):
  the flanking model that consumes the laboratory $R$, $L_n$ and $K_{ij}$.
- [Sound Power](https://jmrplens.github.io/phonometry/guides/sound-power/): the `LW` methods that share the
  absorption-area machinery of the receiving room.
- API reference: [`building.lab_insulation`](https://jmrplens.github.io/phonometry/reference/api/building/lab-insulation/), [`building.intensity_insulation`](https://jmrplens.github.io/phonometry/reference/api/building/intensity-insulation/) and [`building.flanking_transmission`](https://jmrplens.github.io/phonometry/reference/api/building/flanking-transmission/).

---


<!-- source: docs/insulation-prediction.md | canonical: https://jmrplens.github.io/phonometry/guides/insulation-prediction/ -->

# Predicting Sound Insulation (EN 12354)

A laboratory rating describes an element in isolation; a building also
transmits sound along every flanking path. This page covers the EN 12354
prediction of in-situ performance from laboratory element data: the airborne
and impact flanking models of EN 12354-1/2 with their junction vibration
reduction indices, and the façade insulation and outdoor radiation of
EN 12354-3/4. The measured quantities the model is checked against live in
[Field Insulation Measurement and Ratings](https://jmrplens.github.io/phonometry/guides/insulation-field/); the
laboratory inputs come from
[Laboratory Insulation Measurement](https://jmrplens.github.io/phonometry/guides/insulation-lab/).

## Predicting performance (EN 12354)

A laboratory rating describes an element in isolation, yet the sound a building
actually transmits also travels *around* the partition (along the floor, up the
façade, through the flanking walls), re-radiating into the receiving room. This
**flanking transmission** is the whole difference between the laboratory $R$ and
the field $R'$. EN 12354 predicts the in-situ apparent rating from the
laboratory ratings of the elements plus the vibration transmission of their
junctions.

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/diagram_flanking_paths_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/diagram_flanking_paths.svg" alt="The direct path Dd through the separating element and the three flanking paths Ff, Df and Fd across each junction between a flanking element and the separating element" width="92%"></picture>

Each junction between a flanking element and the separating element carries
three paths, $Ff$ (flanking→flanking), $Df$ (direct→flanking) and $Fd$
(flanking→direct), alongside the single direct path $Dd$.

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/anim_flanking_paths_dark.gif"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/anim_flanking_paths.gif" alt="Animation: energy pulses leave the source room over the direct Dd path and the flanking Ff, Fd and Df paths, shrinking at each element and junction, and every path label lights up as its pulse re-radiates into the receiving room" width="640" height="360" loading="lazy"></picture>

[Watch the high-resolution video (WebM)](https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/anim_flanking_paths.webm)

The **simplified single-number model** combines them energetically (Formula 26):

$$
R'_w = -10 \log_{10}\Big[ 10^{-R_{Dd,w}/10}
      + \sum 10^{-R_{Ff,w}/10} + \sum 10^{-R_{Df,w}/10}
      + \sum 10^{-R_{Fd,w}/10} \Big],
$$

with the direct path $R_{Dd,w} = R_{s,w} + \Delta R_{Dd,w}$ (Formula 27) and each
flanking path (Formula 28a)

$$
R_{ij,w} = \tfrac{R_{i,w} + R_{j,w}}{2} + \Delta R_{ij,w} + K_{ij}
         + 10 \log_{10}\frac{S_s}{l_0\ l_f},
$$

where $l_0 = 1$ m is the reference coupling length, $l_f$ the junction coupling
length and $K_{ij}$ the junction's **vibration reduction index** (Annex E,
empirical in the mass ratio $M = \log_{10}(m'_{\perp,i}/m'_i)$).

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/prediction_flanking_demo_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/prediction_flanking_demo.svg" alt="Per-path sound reduction indices for the EN 12354-1 Annex H.3 example and each path's share of the transmitted energy, showing the direct path dominating at R'w = 52 dB" width="80%"></picture>

```python
import numpy as np
from phonometry import building

# EN 12354-1 Annex H.3: a separating wall Rs,w = 57 dB, area Ss = 11.5 m², with
# four flanking elements. The simplified model reads each junction's Kij at
# 500 Hz from the mass ratio m'perp / m' (Annex E) — here the floor's rigid
# cross-junction (the mass ratio is itself rounded, hence 12.5 vs Annex 12.4):
print(round(building.junction_vibration_reduction("rigid_cross", "through", 1.61), 1))  # 12.5  KFf
print(round(building.junction_vibration_reduction("rigid_cross", "corner",  1.61), 1))  #  8.9  KFd = KDf

# Build each element's three flanking paths (Ff, Df, Fd) from the Annex H
# tabulated Kij, then combine the direct path Dd energetically (Formula 26).
elements = [   # (name, Rw, KFf, KFd = KDf, coupling length lf)
    ("floor",    49, 12.4,  8.9, 4.50),
    ("ceiling",  46, 14.4,  9.2, 4.50),
    ("facade",   42, 12.6,  6.7, 2.55),
    ("int-wall", 33, 33.5, 15.7, 2.55),
]
paths = []
for name, rw, k_ff, k_fd, lf in elements:
    paths += building.flanking_element(label=name, r_flanking=rw, r_separating=57,
                              k_ff=k_ff, k_fd=k_fd, k_df=k_fd,
                              separating_area=11.5, coupling_length=lf)

res = building.predicted_airborne_insulation(r_direct=57.0, flanking_paths=paths)
print(round(res.r_prime_w, 1))                          # 52.2  ->  R'w = 52 dB
print(res.dominant.label, round(res.dominant.fraction, 2))   # Dd 0.33 (direct dominates)
```

<details>
<summary>Show the code for this figure</summary>

```python
import matplotlib.pyplot as plt
from phonometry import building

# Build each element's three flanking paths (Ff, Df, Fd) from the Annex H
# tabulated Kij, then combine the direct path Dd energetically (Formula 26).
elements = [   # (name, Rw, KFf, KFd = KDf, coupling length lf)
    ("floor",    49, 12.4,  8.9, 4.50),
    ("ceiling",  46, 14.4,  9.2, 4.50),
    ("facade",   42, 12.6,  6.7, 2.55),
    ("int-wall", 33, 33.5, 15.7, 2.55),
]
paths = []
for name, rw, k_ff, k_fd, lf in elements:
    paths += building.flanking_element(label=name, r_flanking=rw, r_separating=57,
                              k_ff=k_ff, k_fd=k_fd, k_df=k_fd,
                              separating_area=11.5, coupling_length=lf)
res = building.predicted_airborne_insulation(r_direct=57.0, flanking_paths=paths)

# Per-path sound reduction index and each path's share of the transmitted
# energy for the Annex H.3 result computed above.
labels = [p.label for p in res.paths]
r_w = [p.r_w for p in res.paths]
frac = [100.0 * p.fraction for p in res.paths]

fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(9, 6), sharex=True)
ax1.bar(labels, r_w, color="tab:blue")
ax1.axhline(res.r_prime_w, ls="--", color="k", label=f"R'w = {res.r_prime_w:.1f} dB")
ax1.set_ylabel("Path Rij,w [dB]"); ax1.legend()
ax2.bar(labels, frac, color="tab:orange")
ax2.set_ylabel("Energy share [%]"); ax2.set_xlabel("Transmission path")
for ax in (ax1, ax2):
    ax.tick_params(axis="x", rotation=45)
fig.suptitle("EN 12354-1 Annex H.3 — flanking transmission")
fig.tight_layout()
plt.show()
```

</details>

Every added flanking path strictly lowers $R'_w$ below the direct $R_{Dd,w} = 57$;
`res.paths` exposes each path's share of the transmitted energy so the dominant
path is visible. `flanking_element` is a convenience that builds one junction's
three paths at once; the single-path constructor behind it, `flanking_path`,
builds one `Ff`, `Df` or `Fd` path at a time (Formula 28a). Clause 4.4.2 also
enforces a floor $K_{ij} \ge K_{ij,\min}$ from the junction geometry
(Formula 29). Pass the flanking-element area to
`flanking_element(..., flanking_area=...)` and the clamp is applied
automatically per path; or compute the floor yourself with
`junction_min_vibration_reduction` and pass it to
`flanking_path(..., kij_min=...)`, which raises a below-floor $K_{ij}$ to the
minimum:

```python
from phonometry import building
# Kij,min = 10 lg[lf·l0·(1/Si + 1/Sj)]; large elements give a low (here negative)
# floor, so a realistic tabulated Kij is rarely clamped; but small, light
# elements can push it above the tabulated value (e.g. lf = 4 m, S = 1.5 m²
# gives 7.3 dB, over the 5 dB lightweight floor).
print(round(building.junction_min_vibration_reduction(coupling_length=4.5,
                                             s_i=11.5, s_j=11.5), 1))     # -1.1
```

The impact counterpart (EN 12354-2, Formula 21) is a direct subtraction:
$L'_{n,w} = L_{n,w,eq} - \Delta L_w + K$, with the bare-floor equivalent level
$L_{n,w,eq} = 164 - 35 \log_{10}(m'/m'_0)$ (Annex B), the covering improvement
$\Delta L_w$ (ISO 717-2) and the flanking correction $K$ from Table 1.

```python
from phonometry import building

# EN 12354-2 Annex E.3: a 0.14 m concrete floor (m' = 322 kg/m²) with a floating
# floor (ΔLw = 33 dB), rooms one above the other, mean flanking mass 145 kg/m².
ln_eq = building.equivalent_impact_level(322.0)                   # 164 - 35 lg(m')
k = building.impact_flanking_correction(322.0, 145.0)             # Table 1 (sep 322, flk 145)
imp = building.predicted_impact_insulation(ln_w_eq=ln_eq, delta_l_w=33.0, k_correction=k)
print(round(ln_eq, 1), k, round(imp.l_prime_n_w, 1))     # 76.2 2 45.2  ->  L'n,w = 45 dB

# Exact Formula (3): L'nT,w = L'n,w - 10 lg(0.032 V). Annex E.3's own rounding
# of the factor to 10 lg(V/30) sits 0.18 dB below; both give L'nT,w = 43 dB.
print(round(building.standardized_impact_level(imp.l_prime_n_w, 50.0), 1))   # 43.2  L'nT,w
```

The airborne counterpart of that closure is Formula (5b),
$D_{nT} = R' + 10\lg(0.32\,V/S_s)$, exposed as `standardized_level_difference`;
it closes the Annex H.3 example (`standardized_level_difference(52.2, 50.0,
11.5)` gives 53.6 dB, the printed $V/(3S)$ chain 53.8 dB, both rounding to
$D_{nT,w} = 54$ dB).

The three insulation guides close a loop: the
[laboratory](https://jmrplens.github.io/phonometry/guides/insulation-lab/) measures $R$ and $K_{ij}$ element by element
and junction by junction, this model assembles them into a predicted $R'_w$,
and the [field measurement](https://jmrplens.github.io/phonometry/guides/insulation-field/) checks the built result.
When the measured value lands well below the prediction, look for a
construction defect on the dominant path the model reports: a rigid bridge
across a resilient junction, a missing lining, a leak around the separating
element.

### `junction_vibration_reduction()` / `flanking_element()` parameters

| Parameter | Type | Units | Range / default | Notes |
| :--- | :--- | :--- | :--- | :--- |
| `junction_type` | str | — | `'rigid_cross'` / `'rigid_t'` / `'flexible_t'` / `'lightweight_facade'` / `'lightweight_double_homogeneous'` / `'lightweight_double_coupled'` / `'corner'` / `'thickness_change'` | Junction geometry (Annex E.3-E.9) |
| `path` | str | — | `'through'` (K13) / `'corner'` (K12 = K23) / `'double_leaf'` (K24) | Path branch |
| `mass_ratio` | float | — | > 0 | `m'⊥,i / m'i` (Formula E.2) |
| `frequency` | float | Hz | default `500` | `flexible_t` and the E.7/E.8 double-leaf junctions are frequency-dependent |
| `r_flanking` / `r_separating` | float | dB | — | Weighted indices of the flanking / separating element |
| `k_ff` / `k_fd` / `k_df` | float | dB | — | Junction `Kij` for the three paths |
| `separating_area` | float | m² | > 0 | Separating-element area `Ss` |
| `coupling_length` | float | m | > 0 | Junction coupling length `lf` |
| `delta_r_ff` / `delta_r_fd` / `delta_r_df` | float | dB | default `0` | Lining improvements per path |
| `flanking_area` | float | m² | default `None` | Flanking-element area `SF`; enables the automatic `Kij,min` clamp (Clause 4.4.2 / Formula 29) |

### `flanking_path()` parameters

| Parameter | Type | Units | Range / default | Notes |
| :--- | :--- | :--- | :--- | :--- |
| `label` | str | — | — | Display name of the path |
| `kind` | str | — | `'Ff'` / `'Df'` / `'Fd'` | Which flanking branch the path is |
| `r_source` / `r_receive` | float | dB | — | Weighted indices of the source-side / receive-side elements |
| `k_ij` | float | dB | — | Junction vibration-reduction index for this path |
| `separating_area` | float | m² | > 0 | Separating-element area `Ss` |
| `coupling_length` | float | m | > 0 | Junction coupling length `lf` |
| `delta_r` | float | dB | default `0` | Lining improvement on this path |
| `kij_min` | float | dB | default `None` | When given, `k_ij` is floored at this Formula (29) minimum |

`predicted_airborne_insulation()` returns an `AirbornePredictionResult`
(`r_prime_w`, `r_direct_w`, `paths` of `PathContribution`, `dominant`);
`predicted_impact_insulation()` an `ImpactPredictionResult` (`l_prime_n_w`,
`ln_w_eq`, `delta_l_w`, `k_correction`). The simplified model carries a reported
standard deviation of about 2 dB (Clause 5).

### EN 12354 prediction report (`.report()`)

Both prediction results write a one-page **prediction** report through a
`report(path)` method. Unlike the measurement fiches, the reported result is an
*estimate* of the in-situ performance from the elements' laboratory data plus
the flanking transmission across the junctions; the sheet states this
explicitly and is labelled a prediction, never a measurement.
`AirbornePredictionResult.report()` renders the predicted apparent sound
reduction index fiche (the transmission-path table beside the per-path
share-of-energy plot, the boxed `R'w`); `ImpactPredictionResult.report()`
renders the predicted apparent impact-level fiche (the Formula (21) term table
beside the term plot, the boxed `L'n,w`). Each names EN/ISO 12354-1/-2 and the
ISO 717 rating part in its basis line, prints the model's ~2 dB standard
deviation and, when a `requirement` is supplied, adds a PASS/FAIL verdict
(airborne passes at or above it, impact at or below it). `verbose=True` annexes
each transmission path's share of the transmitted energy to the airborne table;
metadata, `language="es"` and the `phonometry[report]` extra behave as in the
measurement fiches.

```python
from phonometry import building, ReportMetadata

# Airborne prediction (EN 12354-1 Annex H.3): a separating wall Rs,w = 57 dB
# flanked by four elements -> R'w = 52 dB.
paths = []
for label, rw, kff, kfd, lf in [
    ("floor", 49.0, 12.4, 8.9, 4.5), ("ceiling", 46.0, 14.4, 9.2, 4.5),
    ("facade", 42.0, 12.6, 6.7, 2.55), ("intwall", 33.0, 33.5, 15.7, 2.55),
]:
    ff, df, fd = building.flanking_element(
        label=label, r_flanking=rw, r_separating=57.0,
        k_ff=kff, k_fd=kfd, k_df=kfd, separating_area=11.5, coupling_length=lf)
    paths += [ff, df, fd]
air = building.predicted_airborne_insulation(r_direct=57.0, flanking_paths=paths)
air.report("Rpw_prediction.pdf", metadata=ReportMetadata(
    specimen="Separating wall, Rs,w = 57 dB", area=11.5,
    source_volume=53.0, receiving_volume=50.0, requirement=50.0,
    notes="Flanking: floor/ceiling/facade/internal wall."))   # R'w = 52 dB

# Impact prediction (EN 12354-2 Annex E.3): a concrete floor (m' = 322 kg/m2)
# with a floating floor (DLw = 33 dB) -> L'n,w = 45 dB.
ln_eq = building.equivalent_impact_level(322.0)
k = building.impact_flanking_correction(322.0, 145.0)
imp = building.predicted_impact_insulation(ln_w_eq=ln_eq, delta_l_w=33.0, k_correction=k)
imp.report("Lnw_prediction.pdf",
           metadata=ReportMetadata(mass_per_area=322.0, requirement=53.0))  # L'n,w = 45 dB
```

[![Predicted airborne EN 12354-1 example report: metadata header, the transmission-path table beside the per-path share-of-energy chart, boxed predicted R'w = 52 dB, the prediction statement and a PASS verdict](https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/reports/iso12354_airborne_prediction_example.webp)](https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/reports/iso12354_airborne_prediction_example.pdf)

[![Predicted impact EN 12354-2 example report: metadata header, the Formula (21) term table beside the term chart, boxed predicted L'n,w = 45 dB, the prediction statement and a PASS verdict against the 53 dB requirement](https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/reports/iso12354_impact_prediction_example.webp)](https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/reports/iso12354_impact_prediction_example.pdf)

### Façade insulation & radiated power (EN 12354-3 / -4)

Parts 3 and 4 predict the two directions across the building envelope, both from
the same energy summation of the element **transmission factors**
$\tau = 10^{-R/10}$, area-weighted by $S_i/S$ (a small element or air path enters
through its element-normalized level difference $D_{n,e}$ with the reference area
$A_0 = 10\ \text{m}^2$):

$$
R' = -10 \log_{10}\!\Big( \sum_i \tfrac{S_i}{S}\,10^{-R_i/10}
                          + \sum_k \tfrac{A_0}{S}\,10^{-D_{n,e,k}/10} \Big).
$$

**Part 3: outdoor → indoor.** From $R'$ (Formula 10) follow the loudspeaker- and
traffic-referenced indices $R_{45} = R'+1$ and $R_{tr,s} = R'$, and the primary
output, the standardized level difference at 2 m (Formula 13)

$$
D_{2m,nT} = R' + \Delta L_{fs} + 10 \log_{10}\frac{V}{6\,T_0\,S}, \qquad T_0 = 0.5\ \text{s},
$$

with the façade-shape term $\Delta L_{fs}$ (Annex C; 0 dB for a flat reflecting
façade; `facade_shape_level_difference` looks it up from the Figure C.2 table
for galleries, balconies and terraces, interpolating over the underside
absorption $\alpha_w$). Single-number ratings reuse EN ISO 717-1
(`weighted_rating`).

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/facade_prediction_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/facade_prediction.svg" alt="Per-element partial sound reduction indices and the resulting façade apparent reduction R' and standardized level difference D2m,nT for the EN 12354-3 Annex F worked example, the air inlet limiting the low bands" width="80%"></picture>

```python
from phonometry import building

# EN 12354-3 Annex F: an 11.3 m² façade (V = 50 m³, flat so ΔLfs = 0) of a double
# wall, a window, a small skylight and an acoustically-treated air inlet (a Dn,e
# element).
elements = [
    building.FacadeElement("wall",     area=6.0, r=[41, 46, 52, 58, 64]),   # octave 125-2000
    building.FacadeElement("window",   area=4.5, r=[23, 22, 30, 36, 37]),
    building.FacadeElement("skylight", area=0.5, r=[24, 27, 30, 33, 30]),
    building.FacadeElement("air inlet", dn_e=[28, 23, 25, 38, 44]),         # small element
]
fac = building.facade_sound_reduction(elements, area=11.3, volume=50.0,
                             frequencies=[125, 250, 500, 1000, 2000], bands="octave")
print(fac.r_tr_s_w, fac.c_tr, fac.d_2m_nt_w)   # 31 -3 33  (R'tr,s,w / Ctr / D2m,nT,w)
```

<details>
<summary>Show the code for this figure</summary>

```python
import numpy as np
import matplotlib.pyplot as plt
from phonometry import building

# EN 12354-3 Annex F: an 11.3 m² façade (V = 50 m³, flat so ΔLfs = 0) of a double
# wall, a window, a small skylight and an acoustically-treated air inlet (a Dn,e
# element).
elements = [
    building.FacadeElement("wall",     area=6.0, r=[41, 46, 52, 58, 64]),   # octave 125-2000
    building.FacadeElement("window",   area=4.5, r=[23, 22, 30, 36, 37]),
    building.FacadeElement("skylight", area=0.5, r=[24, 27, 30, 33, 30]),
    building.FacadeElement("air inlet", dn_e=[28, 23, 25, 38, 44]),         # small element
]
fac = building.facade_sound_reduction(elements, area=11.3, volume=50.0,
                             frequencies=[125, 250, 500, 1000, 2000], bands="octave")

x = np.arange(5)
fig, ax = plt.subplots(figsize=(9, 5.5))
for name, rp in fac.element_r.items():
    ax.plot(x, rp, "--", alpha=0.6, marker=".", label=f"Rp — {name}")
ax.plot(x, fac.r_prime, "k-", lw=2.5, marker="o", label="R′ (façade)")
ax.plot(x, fac.d_2m_nt, lw=2, marker="s", label="D2m,nT")
ax.set_xticks(x); ax.set_xticklabels([125, 250, 500, 1000, 2000])
ax.set_xlabel("Frequency [Hz]"); ax.set_ylabel("Index / level difference [dB]")
ax.set_title("EN 12354-3 façade sound insulation (Annex F)")
ax.legend(ncol=2); ax.grid(alpha=0.4)
fig.tight_layout(); plt.show()
```

</details>

The façade prediction also writes a one-page **prediction** report through a
`report(path)` method, the same layout as the airborne and impact prediction
fiches. `FacadePredictionResult.report()` renders the façade-element table (each
element's weighted partial index `Rp,w`) beside the per-element / `R'` /
`D2m,nT` plot, the boxed predicted `D2m,nT,w` (with `R'tr,s,w` and `Ctr`), the
prediction statement and, when a `requirement` is supplied, a PASS/FAIL verdict
(the level difference passes at or above it). `verbose=True` annexes each
element's share of the transmitted sound energy, which singles out the limiting
element (the air inlet here, not the wall). The report needs the ISO 717-1
single-number ratings, so build the result on the 5 octave or 16 one-third-octave
bands. The applicable `ReportMetadata` fields describe the predicted situation:
`specimen` (the façade element set), `area` (the exposed façade area), the
receiving-room `receiving_volume`, the outdoor/traffic situation in `test_room`,
plus the calculator / laboratory identity fields (`client`, `manufacturer`,
`measurement_standard`, `laboratory`, `operator`, `report_id`, `test_date`), a
free-text façade-shape and model summary in `notes` and the target `D2m,nT,w` in
`requirement`. Metadata, `language="es"` and the `phonometry[report]` extra
behave as in the measurement fiches.

```python
from phonometry import building, ReportMetadata

# EN 12354-3 Annex F facade -> D2m,nT,w = 33 dB (R'tr,s,w = 31, Ctr = -3).
elements = [
    building.FacadeElement("Masonry wall", area=6.0, r=[41, 46, 52, 58, 64]),
    building.FacadeElement("Glazing",      area=4.5, r=[23, 22, 30, 36, 37]),
    building.FacadeElement("Roof light",   area=0.5, r=[24, 27, 30, 33, 30]),
    building.FacadeElement("Air inlet", dn_e=[28, 23, 25, 38, 44]),
]
fac = building.facade_sound_reduction(elements, area=11.3, volume=50.0,
                             frequencies=[125, 250, 500, 1000, 2000], bands="octave")
fac.report("D2mnT_prediction.pdf", metadata=ReportMetadata(
    specimen="Masonry wall + window + roof light + air inlet", area=11.3,
    receiving_volume=50.0, requirement=30.0,
    notes="Flat facade, ΔLfs = 0 dB (Annex C)."))          # D2m,nT,w = 33 dB
```

[![Predicted facade EN 12354-3 example report: metadata header, the facade-element table beside the per-element partial-index and R' / D2m,nT chart, boxed predicted D2m,nT,w = 33 dB, the prediction statement and a PASS verdict against the 30 dB requirement](https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/reports/iso12354_facade_prediction_example.webp)](https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/reports/iso12354_facade_prediction_example.pdf)

**Part 4: indoor → outdoor.** The sound power level radiated by a segment
(Formula 2) is $L_W = L_{p,in} + C_d - R' + 10 \log_{10}(S/S_0)$ with $S_0 = 1$ m²
and the inside-field diffusivity term $C_d$ (Annex B; −6 dB ideal diffuse, −5 dB
average industrial). Openings are elements whose "R" is the silencer insertion
loss (a bare opening is 0 dB). The exterior level follows from the simplified
Annex E attenuation $A_{tot}$ of a finite radiating side, $L_p = L_W - A_{tot}$.

```python
from phonometry import building

# EN 12354-4 Annex G, side 1: a 10×20 m concrete wall segment with a 6×4 m
# industrial door, inside level Lp,in, Cd = -5 dB. The 40 dB cap on R' is an
# Annex G example footnote (field leaks), not part of Formula (2)/(3): pass it
# explicitly to reproduce Annex G; by default no cap is applied.
bands = [63, 125, 250, 500, 1000, 2000, 4000, 8000]
seg = building.radiated_sound_power(
    [building.FacadeElement("wall", area=176.0, r=[32, 36, 36, 33, 39, 49, 57, 63]),
     building.FacadeElement("door", area=24.0,  r=[21, 23, 28, 30, 30, 30, 30, 30])],
    lp_in=[70, 74, 76, 72, 70, 67, 62, 57], area=200.0, c_d=-5.0,
    r_prime_cap=40.0, octave_bands=bands)
print(round(seg.l_w[0], 1), round(seg.l_w[1], 1))     # 59.8 61.2  (LW at 63/125 Hz)

# Exterior level 5 m in front of the centre of the 60×10 m side (LWA = 62.9 dB(A)).
a_tot = building.outdoor_attenuation(width=60.0, height=10.0, distance=5.0)
print(round(a_tot, 1), round(building.outdoor_level(62.9, a_tot), 1))   # 26.3 36.6
```

> **Worked-example note.** The 2000 worked examples carry small internal rounding
> inconsistencies at the higher octave bands (Part 3's printed $R'$ disagrees with
> its own per-element partial indices at 1 k/2 k; Part 4's $R'$ rows above 500 Hz
> disagree with its Table G.2 inputs). The implementation is faithful to the
> formulas: it reproduces the low bands, every single-number rating and the whole
> Annex E propagation exactly.

### `FacadeElement` / `facade_sound_reduction()` / `radiated_sound_power()` parameters

| Parameter | Type | Units | Range / default | Notes |
| :--- | :--- | :--- | :--- | :--- |
| `FacadeElement.area` | float | m² | > 0 for `r` / `insertion_loss` | Element area $S_i$ (ignored for `dn_e`) |
| `FacadeElement.r` / `dn_e` / `insertion_loss` | float or seq | dB | give exactly one | Area element $R_i$ / small-element $D_{n,e}$ / opening insertion loss |
| `facade_sound_reduction(area)` | float | m² | > 0 | Total façade area $S$ |
| `facade_sound_reduction(volume)` | float | m³ | > 0 | Receiving-room volume $V$ (Formula 13) |
| `facade_sound_reduction(delta_l_fs)` | float | dB | default `0` | Façade-shape term $\Delta L_{fs}$ (Annex C; look it up with `facade_shape_level_difference`) |
| `radiated_sound_power(lp_in)` | float or seq | dB | — | Inside level $L_{p,in}$ per band |
| `radiated_sound_power(c_d)` | float | dB | default `-6` | Diffusivity term $C_d$ (Annex B) |
| `radiated_sound_power(r_prime_cap)` | float | dB | default `None` (off) | Optional field cap on $R'$, an Annex G example footnote (it uses 40 dB), not part of Formula (2)/(3) |
| `radiated_sound_power(octave_bands)` | seq of int | Hz | default `None` | Octave centres matching the bands; enables the A-weighted $L_{WA}$ |
| `facade_sound_reduction(frequencies)` | seq | Hz | default `None`; length = band count | Band centres carried on the result for plotting |
| `outdoor_attenuation(width, height, distance)` | float | m | > 0 | Finite radiating side and reception distance (Annex E) |
| `outdoor_level(l_w, attenuation)` | float or seq | dB | broadcast-compatible | Exterior $L_p$ from one or more sides (Formula E.1) |

`facade_sound_reduction()` returns a `FacadePredictionResult` (`r_prime`, `r_45`,
`r_tr_s`, `d_2m_nt`, `element_r`, and the `r_tr_s_w` / `d_2m_nt_w` / `c_tr` single
numbers); `radiated_sound_power()` a `RadiatedPowerResult` (`l_w`, `r_prime`,
`l_w_dba`). Both expose `.plot()`.

## References

- Hopkins, C. (2007). *Sound insulation*. Butterworth-Heinemann.
  ISBN 978-0-7506-6526-1.
  [doi:10.4324/9780080550473](https://doi.org/10.4324/9780080550473).
  The full treatment of flanking transmission and the EN 12354 prediction
  framework, from the vibration reduction index to its statistical basis.

## Standards

EN 12354-1:2000 and EN 12354-2:2000, which give the simplified
flanking-transmission predictions (Annex E junctions, worked examples H.3 and
E.3); EN 12354-3:2000 and EN 12354-4:2000, which cover the façade sound insulation and
outdoor-radiation predictions (Annex F and Annex G worked examples).

## See also

- [Field Insulation Measurement and Ratings](https://jmrplens.github.io/phonometry/guides/insulation-field/):
  the measured in-situ quantities the prediction is checked against.
- [Laboratory Insulation Measurement](https://jmrplens.github.io/phonometry/guides/insulation-lab/): the
  ISO 10140 ratings and ISO 10848 junction data the model consumes.
- [Sound absorption in enclosed spaces (EN 12354-6)](enclosed-space-absorption.md):
  the absorption member of the same EN 12354 family.
- [Dynamic stiffness of resilient materials (EN 29052-1)](dynamic-stiffness.md):
  the s' input to the EN 12354-2 floating-floor term.
- API reference: [`building.building_prediction`](https://jmrplens.github.io/phonometry/reference/api/building/building-prediction/) and [`building.facade_prediction`](https://jmrplens.github.io/phonometry/reference/api/building/facade-prediction/).

---


<!-- source: docs/panel-sound-insulation.md | canonical: https://jmrplens.github.io/phonometry/guides/panel-sound-insulation/ -->

# Predicting Panel Sound Insulation (mass law, coincidence, double walls)

A laboratory test measures the sound reduction index $R$ of a finished element;
this page instead **predicts** $R(f)$ from the physical properties of the
construction, so a partition can be designed before it is built. It covers the
airborne insulation of a single panel (the mass law and the coincidence dip),
the double wall (its mass-spring-mass resonance), the transmission through slits
and apertures that caps any real construction, the radiation efficiency of a
bending plate, and the point mobilities that set the vibrational power a
structure absorbs. The measured counterparts these predictions feed live in
[Predicting Sound Insulation (EN 12354)](https://jmrplens.github.io/phonometry/guides/insulation-prediction/) and
[Field Insulation Measurement and Ratings](https://jmrplens.github.io/phonometry/guides/insulation-field/).

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/panel_insulation_concept_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/panel_insulation_concept.svg" alt="Four panels: the single-panel mass law with its coincidence dip, the double wall with the mass-spring-mass resonance and cavity gain, the plate radiation efficiency rising to unity above the critical frequency, and a composite wall whose 1 % open slit caps R at the open-area limit" width="92%"></picture>

## Single panel: the mass law and coincidence (Bies 7.2)

A limp, non-stiff panel transmits sound by being driven bodily by the incident
pressure. The **normal-incidence mass law** (Bies Eq. 7.40) and its diffuse-field
form (Eq. 7.42) are

$$
TL_0 = 10 \log_{10}\!\left[ 1 + \left(\frac{\pi f\, m''}{\rho_0 c_0}\right)^2 \right],
\qquad TL = TL_0 - \Delta_{\text{band}},
$$

with $m''$ the mass per unit area and $\Delta_{\text{band}} = 5.5$ dB (one-third
octave) or $4.0$ dB (octave). The mass law rises **6 dB per octave and 6 dB per
doubling of mass**. At the **coincidence (critical) frequency** (Bies Eq. 7.3)

$$
f_c = \frac{c_0^2}{2\pi}\sqrt{\frac{m''}{B'}} = \frac{0.55\, c_0^2}{c_L\, h},
$$

the free bending wavelength matches the trace wavelength and the panel goes
transparent: the **coincidence dip**. Sharp's method holds the field-incidence
mass law up to $f_c/2$, drops through a straight line in $\log f$, and from $f_c$
upward follows Eq. 7.44 with the loss factor $\eta$; the dip sits at Bies
design-chart point B, $TL = 20\lg(f_c m'') + 10\lg\eta - 44$.

```python
import numpy as np
from phonometry import (
    coincidence_frequency, plate_bending_stiffness,
    single_panel_transmission_loss,
)

# 6 mm float glass: E = 62 GPa, rho = 2500 kg/m3, nu = 0.24, eta = 0.024.
bands = np.array([100, 125, 160, 200, 250, 315, 400, 500, 630, 800,
                  1000, 1250, 1600, 2000, 2500, 3150], dtype=float)
mass = 2500.0 * 0.006                                 # 15 kg/m2
bp = plate_bending_stiffness(6.2e10, 0.006, 0.24)     # B' [N.m]
fc = coincidence_frequency(mass, bp)
print(round(fc))                                      # 2107 Hz (Hopkins declares ~2079)

res = single_panel_transmission_loss(bands, mass, critical_frequency=fc,
                                     loss_factor=0.024)
print(round(res.rating().rating))                     # 32  ->  Rw = 32 dB (catalogue 6 mm glass)
```

The predicted spectrum plugs straight into the ISO 717-1 rating through
`res.rating()`, and into EN 12354 as the "predicted" element $R$.

## Double wall: the mass-spring-mass resonance (Bies 7.2.6)

Two leaves separated by a cavity behave as a mass-spring-mass system. Below its
**resonance** (Bies Eq. 7.62, Hopkins Eq. 4.73)

$$
f_0 = \frac{1}{2\pi}\sqrt{\frac{s''\,(m_1 + m_2)}{m_1\, m_2}}
    = 60\sqrt{\frac{m_1 + m_2}{m_1\, m_2\, d}}\quad\text{(empty air gap)},
$$

the pair follows the mass law of the *combined* mass; above it the two mass laws
add, boosted by the cavity, until $f_l = c_0/(2\pi d)$ where the boost saturates
at 6 dB (Eq. 7.64). A porous fill lowers $f_0$.

```python
from phonometry import double_wall_transmission_loss, mass_spring_mass_resonance
from phonometry import mass_law_transmission_loss, miki

# Two 12 kg/m2 leaves, 75 mm air gap.
f0 = mass_spring_mass_resonance(12.0, 12.0, 0.075)
print(round(f0))                                          # 89 Hz

# Below f0 the double wall equals the mass law of the total mass 24 kg/m2:
dw = double_wall_transmission_loss(bands, 12.0, 12.0, 0.075)
print(round(float(dw.transmission_loss[0]), 1),
      round(float(mass_law_transmission_loss(bands[0], 24.0)), 1))   # equal

# A mineral-wool fill (a materials porous model) lowers the resonance:
fill = miki([f0], 7000.0)
print(round(mass_spring_mass_resonance(12.0, 12.0, 0.075, cavity_medium=fill)))  # < 89 Hz
```

## Slits, holes and apertures (Hopkins 4.3.10)

A small air path is the real limit on any heavy construction. The transmission
coefficient of a straight slit (Gomperts, Hopkins Eq. 4.99) and of a circular
hole (Wilson & Soroka, Eq. 4.102) are predicted directly, with the slit's
resonances at $d + 2e = z\lambda/2$ (Eq. 4.101). They combine with the wall in
the area-weighted energy sum (Eq. 4.92)

$$
R = -10\log_{10}\!\left( \frac{1}{\sum_n S_n} \sum_n S_n\, 10^{-R_n/10} \right),
$$

so a bare opening of relative area $S_a/S$ caps the composite at $10\lg(S/S_a)$:
a 1 % opening can never do better than 20 dB, whatever the wall.

```python
from phonometry import (
    composite_transmission_loss, slit_transmission_coefficient,
    slit_resonance_frequencies,
)

# A 2 mm x 100 mm-deep slit: transmission peaks at the depth's half-wavelength
# resonances.
print(slit_resonance_frequencies(0.1, 0.002, orders=2).round().tolist())   # [~1500, ~3100]

# A wall of Rw = 50 dB with 1 % of its area open as a slit is capped:
print(round(float(composite_transmission_loss([0.99, 0.01], [50.0, 0.0])), 1))   # 20.0
```

## Radiation efficiency of a bending plate (Hopkins 2.9)

How much airborne power a vibrating plate radiates per unit mean-square velocity
is its **radiation efficiency** $\sigma$, the radiation factor $\varepsilon$
that [Sound Power from Surface Vibration](vibration-sound-power.md) (ISO 7849)
otherwise takes as a measured input. Below the critical frequency the plate
radiates weakly; above it $\sigma \to 1$ (Leppington/Maidanik, Eqs 2.227-2.230):
$\sigma = (1 - f_c/f)^{-1/2}$ for $f > f_c$.

```python
from phonometry import radiation_efficiency, sound_power_from_vibration

# The 6 mm glass pane (1.5 x 1.25 m) of the single-panel example above.
sig = radiation_efficiency(bands, 1.5, 1.25, fc)
print(sig.radiation_efficiency[bands == 2000].round(2))    # ~2.5 (peak at coincidence)

# Feed the prediction straight into ISO 7849 as the radiation factor:
lw = sound_power_from_vibration(velocity_level=80.0, area=1.875,
                                radiation_factor=sig.radiation_efficiency,
                                frequencies=bands)
```

## Point mobilities of infinite structures (Cremer Table 5.1)

The vibrational power a point force injects is $W = \tfrac12 |F|^2\,\mathrm{Re}\{Y\}$
(Cremer Eq. 5.23), so the driving-point **mobility** $Y$ (the reciprocal of the
impedance) sets how much energy a structure absorbs. An infinite thin plate is a
pure resistance $Z = 8\sqrt{B'\,m''}$ (real, frequency independent); an infinite
beam has $Y = (1-\mathrm{j})/(4 m' c_B)$ (45 degrees, falling as
$\omega^{-1/2}$). They supply the receiver mobility EN 12354-5 needs when no
measurement exists.

```python
from phonometry import infinite_plate_impedance, infinite_beam_mobility, injected_power

z_plate = infinite_plate_impedance(bp, mass)          # Z = 8 sqrt(B' m'') [N.s/m]
print(round(z_plate))                                 # real, frequency independent
w = injected_power(force=10.0, mobility=1.0 / z_plate)
print(round(float(w) * 1e3, 3), "mW")                 # W = |F|^2 / (16 sqrt(B' m''))
```

## References

- Bies, D. A., Hansen, C. H., & Howard, C. Q. (2017). *Engineering Noise
  Control* (5th ed.). CRC Press. ISBN 978-1-4987-2405-0.
  [doi:10.1201/9781351228152](https://doi.org/10.1201/9781351228152).
  Section 7.2: the mass law, coincidence frequency, Sharp's single-panel method
  and the double-wall model.
- Hopkins, C. (2007). *Sound insulation*. Butterworth-Heinemann.
  ISBN 978-0-7506-6526-1.
  [doi:10.4324/9780080550473](https://doi.org/10.4324/9780080550473).
  Section 2.9 (plate radiation efficiency) and Section 4.3.10 (slits, holes and
  apertures, composite transmission).
- Cremer, L., Heckl, M., & Petersson, B. A. T. (2005). *Structure-Borne Sound*
  (3rd ed.). Springer. ISBN 978-3-540-22696-3.
  [doi:10.1007/b137728](https://doi.org/10.1007/b137728).
  Chapter 5, Table 5.1: the point impedances and mobilities of infinite
  structures and the injected-power relation.

## See also

- [Predicting Sound Insulation (EN 12354)](https://jmrplens.github.io/phonometry/guides/insulation-prediction/): assembles
  predicted or measured element $R$ into the in-situ $R'_w$.
- [Sound Power from Surface Vibration (ISO 7849)](vibration-sound-power.md):
  consumes the predicted radiation efficiency as its radiation factor.
- [Mechanical mobility and the FRF family (ISO 7626-1)](mechanical-mobility.md):
  the measured counterpart of the theoretical point mobilities.
- [Porous and multilayer absorbers](porous-absorbers.md): the cavity-fill models
  a double wall consumes.
- API reference: [`building.panel_transmission`](https://jmrplens.github.io/phonometry/reference/api/building/panel-transmission/), [`building.aperture_transmission`](https://jmrplens.github.io/phonometry/reference/api/building/aperture-transmission/), [`vibration.radiation_efficiency`](https://jmrplens.github.io/phonometry/reference/api/vibration/radiation-efficiency/) and [`vibration.point_mobility`](https://jmrplens.github.io/phonometry/reference/api/vibration/point-mobility/).

---


<!-- source: docs/outdoor-propagation.md | canonical: https://jmrplens.github.io/phonometry/guides/outdoor-propagation/ -->

# Outdoor Sound Propagation

Predicting the noise a distant source delivers to a receiver outdoors is a
book-keeping exercise: start from the source **sound power** and subtract, band
by band, every mechanism that attenuates the sound on its way: spherical
spreading, the air itself, the ground, and any barrier in between. This page
covers the two parts of ISO 9613 that supply those terms: **ISO 9613-1**, the
pure-tone atmospheric absorption coefficient $\alpha$, and **ISO 9613-2**, the
general method that assembles $\alpha$ with divergence, ground and screening
into an octave-band prediction. The atmospheric coefficient also closes the loop
with room acoustics, since ISO 354 defers its air power-attenuation coefficient
$m$ entirely to $\alpha$.

## 1. Atmospheric absorption (ISO 9613-1)

Air is not lossless. A propagating tone bleeds energy into shear viscosity and
heat conduction (the *classical and rotational* losses that grow as $f^2$) and
into the **vibrational relaxation** of the oxygen and nitrogen molecules, each an
energy store that resonates near a humidity- and temperature-dependent
relaxation frequency. ISO 9613-1:1993, Eq. (5) collects all of this into the
pure-tone attenuation coefficient $\alpha$, in decibels per metre:

$$
\alpha = 8.686\ f^2 \Big[ 1.84\times10^{-11} \big(p_a/p_r\big)^{-1} \big(T/T_0\big)^{1/2}
       + \big(T/T_0\big)^{-5/2} \big( 0.01275\ \tfrac{e^{-2239.1/T}}{f_{rO} + f^2/f_{rO}}
       + 0.1068\ \tfrac{e^{-3352.0/T}}{f_{rN} + f^2/f_{rN}} \big) \Big],
$$

with the oxygen and nitrogen relaxation frequencies $f_{rO}$, $f_{rN}$
(Eq. (3)/(4)), the reference conditions $T_0 = 293.15$ K and $p_r = 101.325$ kPa
(Clause 4.2), and the molar water-vapour concentration $h$ obtained from the
relative humidity by the Annex B psychrometric conversion. Two features dominate
the shape: at low frequency $\alpha \propto f^2$, so it rises steeply; and near
each relaxation frequency the matching term peaks and rolls off. Between them
$\alpha$ climbs about two decades from 50 Hz to 10 kHz, and, because humidity
sets $f_{rO}$, sweeping the humidity moves a relaxation peak across the band, so
the driest air is not always the least absorbing.

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/air_absorption_alpha_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/air_absorption_alpha.svg" alt="ISO 9613-1 pure-tone atmospheric attenuation coefficient alpha in dB/km against frequency on log-log axes, for four temperature and humidity combinations, showing the f-squared low-frequency growth and the humidity-dependent relaxation roll-off" width="80%"></picture>

*The dry 20 °C / 10 % curve absorbs most at mid frequencies, but the humid
curves overtake it below ~200 Hz: the relaxation signature.*

<details>
<summary>Show the code for this figure</summary>

```python
import matplotlib.pyplot as plt
import numpy as np
from phonometry import environmental

freqs = np.geomspace(50.0, 10000.0, 400)
fig, ax = plt.subplots()
for temp, rh in [(20.0, 50.0), (20.0, 10.0), (0.0, 70.0), (30.0, 80.0)]:
    ax.loglog(freqs, environmental.air_attenuation(freqs, temp, rh) * 1000.0,
              label=f"{temp:g} °C, {rh:g} % RH")
ax.set_xlabel("Frequency [Hz]")
ax.set_ylabel("Attenuation coefficient alpha [dB/km]")
ax.legend()
plt.show()
```

</details>

```python
import numpy as np
from phonometry import environmental

bands = [63, 125, 250, 500, 1000, 2000, 4000, 8000]   # octave-band centres [Hz]

# Pure-tone attenuation coefficient alpha [dB/m] at 20 °C, 50 % RH, one atmosphere
alpha = environmental.air_attenuation(bands, temperature=20.0, relative_humidity=50.0)
print(np.round(alpha * 1000.0, 2))          # in dB/km, as Table 1 tabulates
# [  0.12   0.44   1.31   2.73   4.66   9.89  29.67 105.29]

# Reproduce an ISO 9613-1 Table 1 cell exactly (10 °C, 70 %, 1 kHz)
cell = environmental.air_attenuation(1000.0, 10.0, 70.0, exact_midband=True) * 1000.0
print(round(float(cell), 2))                # 3.66  (dB/km, Table 1)

# Feed real conditions into the ISO 354 power attenuation coefficient m [1/m]
m = environmental.air_attenuation_m([1000.0, 4000.0], temperature=20.0, relative_humidity=50.0)
print(np.round(m, 5))                        # [0.00107 0.00683]
```

`air_attenuation` returns dB/m (Table 1 prints dB/km, i.e. $\times 1000$); it is
fully vectorized over `frequencies` with scalar temperature, humidity and
pressure. Passing `exact_midband=True` snaps each requested frequency onto the
exact one-third-octave midband $f_m = 1000 \cdot 10^{k/10}$ (Eq. (6), Note 5)
used to compute Table 1, so the library reproduces every tabulated point to under
0.4 %, the standard's own three-significant-figure precision, far inside its
stated $\pm 10$ % accuracy (Clause 7.1). Inputs outside the tabulated ranges
(−20…+50 °C, 10…100 % RH, 50…10 000 Hz, or above the 200 kPa validity envelope)
still compute but raise an `AtmosphericAbsorptionWarning`; non-physical inputs
(non-positive frequency, humidity outside 0…100 %, sub-absolute-zero
temperature) raise `ValueError`. `air_attenuation_m` composes $\alpha$ with the
ISO 354 conversion $m = \alpha/(10 \lg e)$ so an ISO 354 caller can feed real
atmospheric conditions straight into `absorption_area` /
`absorption_coefficient` instead of hand-entering $m$.

### `air_attenuation()` / `air_attenuation_m()` parameters

| Parameter | Type / shape | Units | Range / default | Notes |
| :--- | :--- | :--- | :--- | :--- |
| `frequencies` | scalar or 1D array | Hz | > 0 | Vectorized; 50–10 000 Hz tabulated |
| `temperature` | float | °C | default `20.0` | −20…+50 tabulated; outside warns |
| `relative_humidity` | float | % | default `50.0` | 10…100 tabulated; `[0, 100]` allowed |
| `pressure` | float | kPa | default `101.325` | ≤ 200 valid (Clause 7); above warns |
| `exact_midband` | bool | — | default `False` | Snap to $f_m = 1000\cdot10^{k/10}$ (reproduces Table 1) |

`air_attenuation` returns $\alpha$ in dB/m; `air_attenuation_m` returns
$m = \alpha/(10 \lg e)$ in 1/m for ISO 354.

### A plottable result: `atmospheric_attenuation()`

For a figure or a quick look, `atmospheric_attenuation()` wraps the same
coefficient in a small `AtmosphericAttenuation` result. It carries the frequency
grid, the coefficient $\alpha$ and the atmospheric conditions, and exposes
`.plot()` for the classic $\alpha$-versus-frequency curve (drawn in dB/km, the
Table 1 unit, on a linear ordinate over a logarithmic frequency axis). Passing a
`distance` also records the total attenuation $A = \alpha\,d$, in decibels, over
that path as `total_attenuation`, the ISO 9613-2 $A_{atm}$ of Eq. (8).

```python
from phonometry import environmental

res = environmental.atmospheric_attenuation(
    [63, 125, 250, 500, 1000, 2000, 4000, 8000],
    temperature=20.0, relative_humidity=50.0,
)
res.plot()   # alpha in dB/km against frequency (needs matplotlib)
```

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/atmospheric_attenuation_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/atmospheric_attenuation.svg" alt="ISO 9613-1 pure-tone atmospheric attenuation coefficient alpha in dB/km against frequency, on a linear decibel ordinate over a logarithmic frequency axis, for the reference 20 degrees Celsius and 50 percent relative humidity atmosphere, produced by the AtmosphericAttenuation result plot method" width="80%"></picture>

*On a linear decibel ordinate the coefficient stays near zero up to about
1 kHz and then climbs steeply, passing 20 dB/km near 3 kHz and reaching over
150 dB/km by 10 kHz at the reference 20 °C / 50 % RH atmosphere.*

<details>
<summary>Show the code for this figure</summary>

```python
import matplotlib.pyplot as plt
import numpy as np
from phonometry import environmental

# One line: the coefficient curve straight from the result.
res = environmental.atmospheric_attenuation(
    np.geomspace(50.0, 10000.0, 400), temperature=20.0, relative_humidity=50.0,
)
res.plot()
plt.show()

# Or by hand from air_attenuation (dB/m, so scale by 1000 for dB/km):
freqs = np.geomspace(50.0, 10000.0, 400)
fig, ax = plt.subplots()
ax.semilogx(freqs, environmental.air_attenuation(freqs, 20.0, 50.0) * 1000.0)
ax.set_xlabel("Frequency [Hz]")
ax.set_ylabel("Attenuation coefficient alpha [dB/km]")
plt.show()
```

</details>

## 2. General method of calculation (ISO 9613-2)

ISO 9613-2:1996 predicts the octave-band level at a receiver **downwind** of a
point source (or under the equivalent moderate temperature inversion, Clause 5).
The equivalent-continuous downwind level is

$$
L_{fT}(DW) = L_W + D_c - A, \qquad A = A_{div} + A_{atm} + A_{gr} + A_{bar} + A_{misc},
$$

(Eq. (3)/(4)) where $L_W$ is the octave-band sound power level, $D_c$ the
directivity correction (directivity index plus a solid-angle index
$D_\Omega$), and $A$ the total attenuation. The library implements the four
general terms of Clause 7; the informative $A_{misc}$ (foliage, industrial
sites, housing; Annex A) and reflections off vertical obstacles (Clause 7.5) are
**not implemented**: the standard treats them as informative and they are left
to the caller.

The geometry of the screening term fixes the vocabulary: the diffraction edge
splits the source-to-receiver distance into $d_{ss}$ and $d_{sr}$, and the extra
path length over the edge is $z = d_{ss} + d_{sr} - d$. When the line of sight
passes *above* the top edge, the standard gives $z$ a negative sign
(`Barrier(line_of_sight_clear=True)`) and Eq. (14) still applies with
$K_{met} = 1$: $D_z$ falls continuously from $10 \lg 3 \approx 4.8$ dB at
grazing incidence to zero as the clearance deepens, never below zero.

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/diagram_outdoor_geometry_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/diagram_outdoor_geometry.svg" alt="ISO 9613-2 source-barrier-receiver geometry: a point source at height hs, a barrier whose top edge splits the path into dss and dsr, and a receiver at height hr, with the blocked direct ray and the diffracted ray over the edge, the path difference z and the Dz formula" width="92%"></picture>

### The four attenuation terms

* **Geometrical divergence** $A_{div} = 20 \log_{10}(d/d_0) + 11$ dB, $d_0 = 1$ m
  (Eq. (7)): spherical spreading from a point source. Exactly 51 dB at 100 m,
  +6 dB per distance doubling. The $+11$ ($= 10 \lg 4\pi$) sets the level at the
  1 m reference distance.
* **Atmospheric absorption** $A_{atm} = \alpha\ d$ (Eq. (8)) with $\alpha$ the
  ISO 9613-1 coefficient, negligible at low frequency and dominant at 8 kHz over
  long paths. $\alpha$ is evaluated at the *exact* base-10 midband behind each
  nominal band label (7 943.3 Hz for "8 kHz"), the convention behind the
  ISO 9613-2 Table 2 coefficients (the nominal-frequency evaluation runs
  ~1.3 % high at 8 kHz). The ISO 9613-2 functions default to 20 °C and 70 %
  relative humidity (one of the Table 2 reference atmospheres the standard
  tabulates) while `air_attenuation` itself defaults to the ISO 9613-1
  usual 50 %.
* **Ground effect** $A_{gr} = A_s + A_r + A_m$ (Eq. (9)) sums a source, receiver
  and middle region, each from the Table 3 functions $a'/b'/c'/d'$ and its
  ground factor $G$ (0 = hard/reflective, 1 = porous/absorbing). A **negative**
  $A_{gr}$ is a net *gain* from constructive ground reflection.
* **Screening** by a barrier is the diffraction insertion loss
  $D_z = 10 \log_{10}\big[ 3 + (C_2/\lambda)\ C_3\ z\ K_{met} \big]$ (Eq. (14)),
  capped at 20 dB (single edge) or 25 dB (double edge). For a top-edge barrier
  the ground effect of the screened path folds into it,
  $A_{bar} = D_z - A_{gr} \ge 0$ (Eq. (12), Note 13); for a lateral barrier
  $A_{bar} = D_z$ and the ground term is retained (Eq. (13)).

`outdoor_propagation_attenuation` assembles the four terms into an
`OutdoorAttenuation` result whose per-band arrays sum, band by band, to
`a_total`, so the divergence, atmospheric, ground and barrier contributions stay
separable. The stacked breakdown makes the frequency character obvious: the
barrier helps most at high frequency (short wavelength) until it saturates at the
cap, atmospheric absorption bites only at 8 kHz, and the ground dip lives in the
mid bands.

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/outdoor_attenuation_breakdown_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/outdoor_attenuation_breakdown.svg" alt="ISO 9613-2 per-octave-band attenuation breakdown as a stacked bar of Adiv, Aatm, Agr and Abar with the total A overlaid, for a 200 m path over porous ground with a 4 m barrier" width="80%"></picture>

<details>
<summary>Show the code for this figure</summary>

```python
import matplotlib.pyplot as plt
import numpy as np
from phonometry import environmental

bands = np.array([63.0, 125.0, 250.0, 500.0, 1000.0, 2000.0, 4000.0, 8000.0])
barrier = environmental.Barrier(source_to_edge=101.0, edge_to_receiver=101.0)
att = environmental.outdoor_propagation_attenuation(
    200.0, 1.5, 1.5, bands, ground_source=1.0, ground_middle=1.0,
    ground_receiver=1.0, barrier=barrier, temperature=15.0,
    relative_humidity=70.0,
)

# One line — the same stacked breakdown with the total overlaid:
att.plot()

# By hand:
x = np.arange(len(bands))
fig, ax = plt.subplots()
# Separate positive and negative baselines: a negative term (Agr is a net
# gain at 63 Hz here) stacks below zero, and the signed heights sum to a_total.
pos_bottom = np.zeros(len(bands))
neg_bottom = np.zeros(len(bands))
for term, label in [(att.a_div, "Adiv — divergence"),
                    (att.a_atm, "Aatm — atmospheric"),
                    (att.a_gr, "Agr — ground"),
                    (att.a_bar, "Abar — barrier")]:
    ax.bar(x, term, bottom=np.where(term >= 0.0, pos_bottom, neg_bottom),
           label=label)
    pos_bottom += np.maximum(term, 0.0)
    neg_bottom += np.minimum(term, 0.0)
ax.plot(x, att.a_total, "D-", color="black", label="A — total")
ax.set_xticks(x)
ax.set_xticklabels([f"{b:g}" for b in bands])
ax.set_xlabel("Octave-band centre frequency [Hz]")
ax.set_ylabel("Attenuation A [dB]")
ax.legend()
plt.show()
```

</details>

```python
import numpy as np
from phonometry import environmental

bands = [63, 125, 250, 500, 1000, 2000, 4000, 8000]   # octave-band centres [Hz]

# A point source and receiver 1.5 m high, 200 m apart over porous ground
# (G = 1), screened midway by a barrier that raises the path over its top edge
# (dss = dsr ~ 101 m). Geometry feeds the pathlength-difference equations.
barrier = environmental.Barrier(source_to_edge=101.0, edge_to_receiver=101.0)
att = environmental.outdoor_propagation_attenuation(
    200.0, source_height=1.5, receiver_height=1.5, frequencies=bands,
    ground_source=1.0, ground_middle=1.0, ground_receiver=1.0,
    barrier=barrier, temperature=15.0, relative_humidity=70.0,
)
print(np.round(att.a_div, 1))     # [57. 57. 57. 57. 57. 57. 57. 57.]  divergence
print(np.round(att.a_gr, 2))      # [-4.65  2.34 13.79  9.76  1.3  -0.   -0.   -0.  ]
print(np.round(att.a_bar, 2))     # [13.78  8.89  0.    6.69 18.01 20.   20.   20.  ]
print(np.round(att.a_total, 1))   # [66.2 68.3 71.  73.9 77.1 78.8 82.3 96. ]

# Predicted receiver level from an octave-band sound power Lw = 95 dB
lw = np.full(len(bands), 95.0)
lp = environmental.predicted_receiver_level(
    lw, 200.0, 1.5, 1.5, bands, 1.0, 1.0, 1.0,
    barrier=barrier, temperature=15.0, relative_humidity=70.0,
)
print(np.round(lp, 1))            # [28.8 26.7 24.  21.1 17.9 16.2 12.7 -1. ]
```

`predicted_receiver_level` composes $L_{fT}(DW) = L_W + D_c - A$ with
$D_c = $ `directivity_index` $+ $ `d_omega`. Pass `c0=` to subtract the
meteorological correction $C_{met}$ (Eq. (21)/(22)) band by band for a long-term
average; note that the standard applies $C_{met}$ to the A-weighted level, so the
per-band form here is a **convenience**, not a literal reading of Clause 8.

### Ground: the alternative A-weighted method

When only the A-weighted receiver level matters and the sound travels over
porous or mostly-porous ground (and is not a pure tone), 7.3.2 offers a simpler
closed form $A_{gr} = 4.8 - \frac{2 h_m}{d}\left(17 + \frac{300}{d}\right) \ge 0$ (Eq. (10), negative
results clamped to zero), paired with the solid-angle index $D_\Omega$
(Eq. (11)) that must then be added to $D_c$. These are exposed as
`ground_attenuation_alternative` and `directivity_omega`, but they are **not
auto-wired** into `outdoor_propagation_attenuation` (which always uses the
general per-region method of 7.3.1); combine them by hand when the alternative
method is appropriate.

```python
from phonometry import environmental

# Alternative ground term (mean path height hm = 2 m, d = 200 m)
print(round(environmental.ground_attenuation_alternative(200.0, 2.0), 2))          # 4.43 dB
# Its companion solid-angle index (add to Dc when using Eq. (10))
print(round(environmental.directivity_omega(1.5, 1.5, 200.0), 2))                  # 3.01 dB
# Long-term meteorological correction (C0 = 2 dB) to subtract from LAT(DW)
print(round(environmental.meteorological_correction(200.0, 1.5, 1.5, 2.0), 2))     # 1.7 dB
```

### The image source behind the ground effect

Every entry of Table 3 is an engineering fit to one physical picture: the
receiver hears two copies of the source, the direct ray $r_1$ and a reflection
that arrives exactly as if it were radiated by an **image source** mirrored
below the ground plane. The two copies interfere according to the path
difference $\delta = r_2 - r_1$ (about $2 h_s h_r / d$ for a grazing far-field
path): in phase they add up to $+6$ dB; at $\delta = \lambda/2$ over a rigid
plane they cancel into a sharp dip.

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/diagram_ground_reflection_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/diagram_ground_reflection.svg" alt="A point source at height hs and a receiver microphone at height hr over a hatched ground plane; a straight direct ray r1 connects them and a reflected ray bounces at the specular point with equal grazing angles; the reflection is unfolded as a dashed straight ray r2 from a ghosted image source mirrored below the ground, and the annotations give the path difference delta = r2 minus r1, the interference phase 2 pi delta over lambda plus the reflection phase, the in-phase gain of up to +6 dB and the deep dip at half a wavelength over hard ground" width="92%"></picture>

Over acoustically hard ground ($G = 0$) the reflection coefficient stays close
to $+1$ in every octave, the long-wavelength sum is fully constructive, and the
general method duly returns $A_{gr} = -3$ dB in each band. Porous ground turns
the reflection coefficient complex and angle-dependent (for a point source
near grazing incidence, the spherical-wave coefficient of the Chien-Soroka
solution, with a ground-wave term no plane-wave picture captures): part of the
reflection flips phase, and the destructive notch lands in the 250 to 1000 Hz
octaves. That notch is precisely what the $a'$ to $d'$ height-and-distance
functions of Table 3 parameterize, with $G$ blending the hard and porous
behaviours. The same two-ray geometry carries over to aircraft lateral
attenuation and to every ray-based outdoor model; Salomons and Attenborough &
Van Renterghem develop the full theory that the engineering fit compresses.

A negative $A_{gr}$ (a net gain) is plain interference: the ground-reflected
wave adds to the direct one. Below, a 400 Hz source 1.5 m over rigid ground
builds the lobe pattern of that interference, and the level sampled on an arc
converges to the two-path image-source model, dips included.

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/anim_fdtd_ground_effect_dark.gif"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/anim_fdtd_ground_effect.gif" alt="Animation: a 2D FDTD simulation of a 400 Hz point source 1.5 metres above rigid ground; the direct and ground-reflected wavefronts interfere and a lobe pattern forms, the ghosted image source below the ground explains the geometry, and the level on an 8 metre arc converges to the two-path image-source model with its predicted nulls" width="640" height="360" loading="lazy"></picture>

[Watch the high-resolution video (WebM)](https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/anim_fdtd_ground_effect.webm)

### `Barrier` and the screening term

The `Barrier` dataclass describes the diffraction geometry directly, which is
the cleanest match to Eq. (14)/(16)/(17). Single diffraction (a thin screen)
leaves `edge_separation=None` ($C_3 = 1$); giving the edge spacing `e` selects
double (thick-barrier) diffraction with the $C_3$ factor of Eq. (15) and the
25 dB cap. `ground_reflections_by_image=True` switches $C_2$ from 20 to 40
(reflections handled by image sources), and `lateral=True` selects vertical-edge
diffraction (Eq. (13), $K_{met}=1$, ground term retained).

The simulation below shows why $D_z$ grows with frequency: against the same
2.5 m screen, a 100 Hz wavefront (λ ≈ 3.4 m) diffracts over the edge and fills
the shadow zone, while at 500 Hz the shadow is deep and sharp. A barrier only
works when the wavelength is short next to the path difference.

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/anim_fdtd_barrier_dark.gif"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/anim_fdtd_barrier.gif" alt="Animation: a 2D FDTD simulation of a point source behind a thin 2.5 metre rigid barrier on reflecting ground, at 100 Hz and 500 Hz side by side; the long wavelength diffracts over the edge and fills the shadow zone with an insertion loss near 8 dB, while the short wavelength is cast into a deep clean shadow of about 17 dB" width="640" height="360" loading="lazy"></picture>

[Watch the high-resolution video (WebM)](https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/anim_fdtd_barrier.webm)

### `outdoor_propagation_attenuation()` parameters

| Parameter | Type / shape | Units | Range / default | Notes |
| :--- | :--- | :--- | :--- | :--- |
| `distance` | float | m | > 0 | Straight-line source–receiver distance $d$ |
| `source_height` / `receiver_height` | float | m | ≥ 0 | $h_s$, $h_r$ above ground |
| `frequencies` | 1D array | Hz | default 8 octaves 63–8000 | `DEFAULT_FREQUENCIES` |
| `ground_source` / `ground_middle` / `ground_receiver` | float | — | `[0, 1]`, default `0.0` | Ground factor $G$ (0 hard, 1 porous) |
| `barrier` | `Barrier` or None | — | default `None` | Screening obstacle |
| `temperature` / `relative_humidity` / `pressure` | float | °C / % / kPa | 20 / 70 / 101.325 | Passed to $A_{atm}$ |
| `projected_distance` | float or None | m | default $\sqrt{d^2-(h_s-h_r)^2}$ | Ground-plane $d_p$ |

Returns an `OutdoorAttenuation` with `a_div`, `a_atm`, `a_gr`, `a_bar`,
`a_total` and `d_omega`, all one value per band; its `.plot()` draws the
stacked per-band breakdown with the total overlaid (the figure above).

### `Barrier` fields

| Field | Type | Units | Default | Notes |
| :--- | :--- | :--- | :--- | :--- |
| `source_to_edge` | float | m | — | $d_{ss}$, source to first edge |
| `edge_to_receiver` | float | m | — | $d_{sr}$, (last) edge to receiver |
| `parallel_distance` | float | m | `0.0` | Component $a$ parallel to the edge |
| `edge_separation` | float or None | m | `None` | $e$; given ⇒ double diffraction (25 dB cap) |
| `ground_reflections_by_image` | bool | — | `False` | `True` ⇒ $C_2 = 40$ |
| `lateral` | bool | — | `False` | `True` ⇒ vertical-edge diffraction (Eq. (13)) |
| `line_of_sight_clear` | bool | — | `False` | `True` ⇒ the sight line passes above the top edge: the path difference takes a negative sign and Kmet = 1 (text after Eq. (16)) |

## 3. Scope, assumptions and pitfalls

### What "favourable propagation conditions" means

ISO 9613-2 does not predict the level under the weather of the moment. Every
equation assumes conditions **favourable to propagation** (Clause 5): wind
blowing from source to receiver (within about 45° of the connecting line, at
roughly 1 to 5 m/s measured 3 to 11 m above ground), or the moderate
ground-based temperature inversion of a clear, calm night, which curves sound
rays downward the same way. Downward refraction closes the acoustic shadow
zones that upwind or neutral atmospheres would form, so the predicted
$L_{fT}(DW)$ is close to the highest level the geometry can deliver: over a
year the actual level is usually lower and rarely meaningfully higher. The
choice is deliberate. Complaints arrive on the still nights when a distant
plant is clearly audible, not on the gusty afternoons when it vanishes, and a
method that predicts the audible case protects the assessment. The long-term
average is recovered by subtracting $C_{met}$ (Eq. (21)/(22)), whose $C_0$
encodes how often the wind actually favours the path (about $+3$ dB when half
the time is favourable, values above 2 dB already exceptional, Notes 20/22).
The stated $\pm 1$ to $\pm 3$ dB accuracy (Table 5) holds under favourable
conditions, for broadband sources, up to 1000 m; beyond that the standard
makes no accuracy claim at all.

### Barrier pitfalls beyond the animation

The screening term is the easiest one to over-trust; four fine points decide
whether a real barrier delivers its computed $D_z$:

* **The caps are physical, not editorial.** $D_z$ is capped at 20 dB for
  single and 25 dB for double diffraction no matter how tall the wall,
  because atmospheric turbulence scatters sound into the shadow zone and sets
  a ceiling that extra height cannot buy back. An insertion loss beyond
  roughly 20 dB is enclosure territory, not screen territory. Eq. (14) itself
  is a smoothed engineering curve in the tradition of Maekawa's screen chart,
  an empirical fit over three decades of Fresnel number $N = 2z/\lambda$.
* **$K_{met}$ quietly erodes distant barriers.** The meteorological factor
  (Eq. (18)) discounts the screening because the same downward-curved rays
  that make conditions favourable also pass over the top edge. Within 100 m
  of source-receiver distance $K_{met} \approx 1$ (Note 17), but for a long
  path with a small path difference it can strip several decibels off a
  barrier that looks generous on the section drawing.
* **Double diffraction is a modest bonus.** A thick obstacle (two edges
  separated by $e$) raises $C_3$ from 1 toward 3 (Eq. (15)), worth at most
  about $10 \lg 3 \approx 4.8$ dB extra plus the higher 25 dB cap. A building
  modelled as a double edge only earns that bonus if both edges really are
  continuous and the roof between them is closed.
* **The ground effect is spent, not kept.** For a top-edge barrier the
  standard folds the screened path's ground effect into the diffraction:
  $A_{bar} = D_z - A_{gr}$ (Eq. (12), Note 13). Over porous ground that was
  already providing 5 to 10 dB of $A_{gr}$ in the mid bands, the *net* gain
  of building the barrier is correspondingly smaller than its nominal $D_z$;
  the two effects do not stack.

An obstacle must also qualify as a barrier at all (Clause 7.4): surface
density at least 10 kg/m², a closed surface without large gaps, and a
horizontal extent normal to the path larger than the wavelength. A slatted
fence or a short container screens far less than Eq. (14) promises.

### ISO 9613-2 or CNOSSOS-EU?

Two frameworks dominate outdoor noise prediction in Europe, and they answer
different questions:

* **ISO 9613-2** is a general *engineering attenuation* method: given the
  octave-band sound power of any source you can decompose into point sources,
  it returns the favourable-condition receiver level. Source emission is out
  of scope; the sound power comes from measurement (the ISO 3740 family) or
  from the equipment supplier. It is the workhorse of industrial-plant and
  environmental impact predictions assessed with ISO 1996-2.
* **CNOSSOS-EU** (Common Noise Assessment Methods in Europe) is the mandatory
  common framework for the strategic noise maps of the Environmental Noise
  Directive 2002/49/EC, adopted as its Annex II by
  [Commission Directive (EU) 2015/996](https://eur-lex.europa.eu/eli/dir/2015/996/oj/eng).
  It bundles *emission* models for road, rail and industrial sources (and
  delegates aircraft to ECAC Doc 29) with its own propagation part derived
  from the French NMPB 2008 method, producing the $L_{den}$/$L_{night}$
  indicators the Directive reports.

The propagation parts disagree by design, not by accident. CNOSSOS-EU
evaluates every path twice, once under a homogeneous atmosphere and once
under favourable (downward-refracting) conditions, and combines the two
long-term with the local occurrence probability of favourable conditions per
path direction; ISO 9613-2 computes only the favourable case and subtracts a
scalar $C_{met}$. The ground effect in CNOSSOS-EU is built from a
path-averaged ground factor over a fitted mean plane, with expressions that
change between the two atmospheres, where ISO 9613-2 uses the fixed
three-region Table 3. Diffraction, too, follows a different formulation that
couples the ground effect on each side of the edge. Run both on the same
geometry and the octave-band results can differ by several decibels, each
internally consistent. The practical rule: END strategic maps and anything
that must be comparable across EU member states use CNOSSOS-EU; a plant
permit, a compliance prediction against a measured sound power, or work
under regulations that cite ISO 9613 use this module. (ISO published a
revised ISO 9613-2 in 2024; this library implements the 1996 edition, the one
most national regulations and the validation literature still reference.)

See the [Theory](https://jmrplens.github.io/phonometry/reference/theory/environment-transport/) page for the full derivation, the
[Room Acoustics guide](https://jmrplens.github.io/phonometry/guides/room-acoustics/) for how $\alpha$ feeds
ISO 354, and the [Occupational Noise Exposure guide](https://jmrplens.github.io/phonometry/guides/occupational-exposure/) for the ISO 9612 occupational
exposure that consumes A-weighted levels.

## References

- Salomons, E. M. (2001). *Computational atmospheric acoustics*. Kluwer
  Academic Publishers. ISBN 978-1-4020-0390-5.
  [doi:10.1007/978-94-010-0660-6](https://doi.org/10.1007/978-94-010-0660-6).
  The wave-based theory (parabolic equation, fast field program, refraction
  and turbulence) that quantifies what the favourable-condition assumption
  and the $K_{met}$ factor approximate.
- Attenborough, K., & Van Renterghem, T. (2021). *Predicting outdoor sound*
  (2nd ed.). CRC Press.
  [doi:10.1201/9780429470806](https://doi.org/10.1201/9780429470806).
  Ground impedance models, the spherical-wave reflection coefficient behind
  the ground dip of section 2, and the meteorological effects on barriers.
- Maekawa, Z. (1968). Noise reduction by screens. *Applied Acoustics*, 1(3),
  157-173.
  [doi:10.1016/0003-682X(68)90020-0](https://doi.org/10.1016/0003-682X(68)90020-0).
  The screen-attenuation chart against Fresnel number that Eq. (14)'s
  diffraction term descends from.
- Kephalopoulos, S., Paviotti, M., & Anfosso-Lédée, F. (2012). *Common noise
  assessment methods in Europe (CNOSSOS-EU)* (EUR 25379 EN). Publications
  Office of the European Union.
  [doi:10.2788/31776](https://doi.org/10.2788/31776),
  [JRC repository](https://publications.jrc.ec.europa.eu/repository/handle/JRC72550).
  The common EU framework contrasted with ISO 9613-2 in section 3.
- International Organization for Standardization. (1993). *Acoustics —
  Attenuation of sound during propagation outdoors — Part 1: Calculation of
  the absorption of sound by the atmosphere* (ISO 9613-1:1993).
  [iso.org catalogue](https://www.iso.org/standard/17426.html).
  The implemented pure-tone attenuation coefficient of section 1.
- International Organization for Standardization. (1996). *Acoustics —
  Attenuation of sound during propagation outdoors — Part 2: General method
  of calculation* (ISO 9613-2:1996; a revised edition was published in 2024,
  this module implements the 1996 method).
  [iso.org catalogue](https://www.iso.org/standard/20649.html).
  The implemented attenuation chain of section 2.

## Standards

ISO 9613-1:1993, *Acoustics — Attenuation of sound during
propagation outdoors — Part 1: Calculation of the absorption of sound by the
atmosphere*: the pure-tone attenuation coefficient $\alpha$ (Eq. (5)) with the
oxygen and nitrogen relaxation frequencies (Eq. (3)/(4)), the Annex B humidity
conversion and the exact Table 1 midbands (Eq. (6), Note 5). ISO 9613-2:1996,
*Acoustics — Attenuation of sound during propagation outdoors — Part 2: General
method of calculation*: the downwind receiver level (Eq. (3)/(4)) assembled
from geometrical divergence (Eq. (7)), atmospheric absorption (Eq. (8)), the
ground effect (Eq. (9), Table 3) with its A-weighted alternative
(Eq. (10)/(11)), barrier screening (Eqs. (12)–(17)) and the meteorological
correction (Eq. (21)/(22)). ISO 354:2003, *Acoustics — Measurement of sound
absorption in a reverberation room*: only the clause 8.1.2.1 conversion
$m = \alpha/(10 \lg e)$ behind `air_attenuation_m`; the reverberation-room
method itself is covered in the [Room Acoustics guide](https://jmrplens.github.io/phonometry/guides/room-acoustics/).

---


<!-- source: docs/ground-barriers.md | canonical: https://jmrplens.github.io/phonometry/guides/ground-barriers/ -->

# Spherical ground effect and advanced barriers (Attenborough / Salomons / Bies)

The [ISO 9613-2 general method](https://jmrplens.github.io/phonometry/guides/outdoor-propagation/) folds the ground and
barrier terms into tabulated, energy-based corrections. This page covers the
underlying wave acoustics in `phonometry.environmental.ground_barriers`: the
**spherical-wave reflection coefficient** of a finite-impedance ground
(Weyl-Van der Pol) and the **wave-theoretic diffraction** of a screen, both in
a homogeneous (non-refracting, non-turbulent) atmosphere. These are the physical
core of the Nord2000 / CNOSSOS ground and barrier models, and they show the
frequency-dependent interference structure the octave-band `Agr`/`Dz` terms
smooth away.

## 1. Spherical-wave ground effect (Weyl-Van der Pol)

The sound field of a point source above a locally reacting ground is the sum of
a direct wave and a reflected wave weighted by the **spherical-wave reflection
coefficient** `Q` (Attenborough Eq. 2.40a; Salomons Eq. 3.2):

$$
p = \frac{e^{ikR_1}}{4\pi R_1} + Q\,\frac{e^{ikR_2}}{4\pi R_2},
$$

with `R1` the source-receiver distance and `R2` the image-source distance. The
coefficient (Attenborough Eq. 2.40c; Salomons Eq. D.58) corrects the plane-wave
coefficient `Rp` for the curvature of the wavefront:

$$
Q = R_p + (1 - R_p)\,F(w), \qquad
R_p = \frac{Z\cos\theta - 1}{Z\cos\theta + 1},
$$

$$
F(w) = 1 + i\sqrt{\pi}\,w\,e^{-w^2}\operatorname{erfc}(-iw), \qquad
w = \sqrt{\tfrac{i k R_2}{2}}\left(\cos\theta + \tfrac{1}{Z}\right).
$$

Here `Z` is the ground surface impedance normalized by `ρc`, `θ` is the angle of
incidence from the ground normal (`cos θ = (hs + hr)/R2`), and the boundary-loss
factor `F(w)` is written through the scaled complementary error function
`e^{-w²} erfc(-iw)`, i.e. the Faddeeva function `scipy.special.wofz`. The second
term of `Q` is the *ground wave* that keeps the field finite at grazing
incidence, where `Rp → -1` and a plane-wave model would predict silence
(Salomons Eq. D.59, D.60, D.57).

The relative sound level (the *excess attenuation*, dB re free field) is
(Salomons Eq. 3.4):

$$
\Delta L = 20\lg\!\left|\,1 + Q\,\frac{R_1}{R_2}\,e^{i k (R_2 - R_1)}\,\right|.
$$

```python
import numpy as np
from phonometry import ground_effect

bands = np.array([63., 125., 250., 500., 1000., 2000., 4000., 8000.])

# Grassland (effective flow resistivity sigma = 200 kPa.s/m^2), source 1 m and
# receiver 1.5 m high, 50 m apart. The impedance comes from the Delany-Bazley
# porous model of phonometry.materials (a semi-infinite ground).
res = ground_effect(bands, 1.0, 1.5, 50.0, flow_resistivity=2e5)
print(res.excess_attenuation)     # the ground dip (dB re free field)
print(res.reflection_coefficient) # complex Q per band
res.plot()                        # excess attenuation vs frequency
```

The ground impedance is either derived from an effective `flow_resistivity`
(via the `delany_bazley` or `miki` model of
[`phonometry.materials`](porous-absorber.md), which model a semi-infinite porous
ground) or supplied directly as a normalized complex `impedance` (a scalar,
per-band array, or a `PorousMediumResult`). A plain `impedance` value is taken
in the `e^{-iωt}` convention of Salomons, in which a passive ground has
`Im(Z) > 0`; the porous models of `phonometry.materials` work in the opposite
`e^{+jωt}` convention (`Im(Z) < 0`), so anything obtained from them (a
`flow_resistivity` or a `PorousMediumResult`) is conjugated internally before
it enters the Weyl-Van der Pol formulas.

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/ground_effect_spherical_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/ground_effect_spherical.svg" alt="Excess attenuation (level re free field) against frequency on a log axis for four ground types. Fresh snow (10 kPa) dips deepest and lowest in frequency, near minus 18 dB around 150 Hz; forest floor (50 kPa) reaches about minus 15 dB near 290 Hz and grassland (200 kPa) about minus 12 dB near 540 Hz; asphalt (20000 kPa) hugs the plus 6 dB hard-ground enhancement limit until a deep dip near 2.4 kHz. A dotted line marks the plus 6 dB hard-ground limit and a solid line the 0 dB free field" width="90%"></picture>

**Limits reproduced by the implementation** (each a pinned test or conformance
anchor): an acoustically hard ground (`|Z| → ∞`) gives `Rp → 1`, `|w| → 0`,
`F → 1` and `Q → 1`, so `ΔL` reaches `+6 dB` in phase; the effective flow
resistivity `σ → ∞` tends to that hard ground; grazing incidence
(`hs, hr → 0`) gives `Rp → -1`; and the ground effect is reciprocal under an
exchange of source and receiver heights.

## 2. Advanced barrier diffraction

Three levels of screening beyond the ISO 9613-2 `Dz` term are provided by
`barrier_insertion_loss` and its building blocks.

**Kurze-Anderson closed form.** The insertion loss of a thin screen as a
function of the Fresnel number `N = (2/λ)(A + B − d)` (Bies Eq. 5.134, with `A`
and `B` the two segments of the shortest source-edge-receiver path and `d` the
straight distance) is (Bies Eq. 5.138; Kurze & Anderson 1971):

$$
\Delta = 5 + 20\lg\!\left(\frac{\sqrt{2\pi N}}{\tanh\sqrt{2\pi N}}\right)\;\text{dB},
$$

which tends to `5 dB` at the shadow boundary `N → 0` and approximates Maekawa's
point-source curve within about 1.5 dB.

```python
from phonometry import barrier_insertion_loss, kurze_anderson_attenuation

kurze_anderson_attenuation(0.0)     # 5.0 dB at the shadow boundary

# A 4 m barrier 50 m from a 1 m source, receiver 1.5 m high at 100 m.
il = barrier_insertion_loss(bands, 1.0, 50.0, 4.0, 100.0, 1.5,
                            method="kurze_anderson")
il.plot()                           # insertion loss vs frequency
```

**Exact rigid half-plane.** With `method="exact"` the wave-theoretic insertion
loss of a rigid thin screen is used: the compact Fresnel-integral form of the
MacDonald / Hadden & Pierce solution (Attenborough Eqs. 9.19-9.20), built from
the auxiliary Fresnel functions. It gives `6 dB` at the shadow boundary (the
field is exactly halved, the flat-wedge limit) and tracks Kurze-Anderson through
the shadow zone.

**Thick barriers.** A `thickness` (top width `e`) lengthens the diffracted path
to `A + e + B`, the double-edge Fresnel number `N = (2/λ)(A + B + e − d)` of
Bies Eq. 5.157, so a thick barrier or a soil mound attenuates monotonically more
than the thin screen of the same height.

**Coherent barrier on the ground.** With a `ground_impedance` (or a
`ground_flow_resistivity`) the four source-image / receiver-image diffracted
paths are combined coherently, each ground reflection weighted by the
spherical-wave coefficient `Q` above (Attenborough Ch. 9; Bies Sec. 5.3.5). This
exposes the ground-barrier interference structure that a purely energetic sum of
`Agr` and `Dz` cannot. As a first-order simplification a single `Q` (over the
overall source-receiver geometry) weights every bounce rather than a separate
coefficient per image path; the model is coherent and reciprocal but not a full
boundary-element solution.

```python
il = barrier_insertion_loss(bands, 1.0, 50.0, 4.0, 100.0, 1.5,
                            method="exact", ground_flow_resistivity=2e5)
il.ground        # True: the four-path coherent ground model was applied
il.plot()
```

## Relation to ISO 9613-2

The tabulated `Agr` and `Dz` of the [ISO 9613-2 method](https://jmrplens.github.io/phonometry/guides/outdoor-propagation/)
are octave-band, energy-based engineering fits; `ground_effect` and
`barrier_insertion_loss` are their narrowband wave-acoustic counterparts. Over
hard ground both agree on the `+6 dB` enhancement and the `5 dB` grazing barrier
floor, but only the wave models resolve the interference dips that move with
geometry, frequency and ground impedance, which is why they are the natural
infrastructure for the meteorological schemes of Nord2000 and CNOSSOS.

## References

- Attenborough, K., & Van Renterghem, T. (2021). *Predicting Outdoor Sound*
  (2nd ed.). CRC Press. ISBN 978-1-138-30655-2.
  [doi:10.1201/9780429470141](https://doi.org/10.1201/9780429470141).
  Chapter 2 (spherical-wave reflection over an impedance ground, the
  Weyl-Van der Pol equation and the boundary-loss factor) and Chapter 9
  (outdoor noise barriers, the MacDonald and Hadden & Pierce diffraction
  solutions).
- Salomons, E. M. (2001). *Computational Atmospheric Acoustics*. Kluwer
  Academic. ISBN 978-1-4020-0390-5.
  [doi:10.1007/978-94-010-0660-6](https://doi.org/10.1007/978-94-010-0660-6).
  Chapter 3 and Appendix D (the two-ray field, the plane- and spherical-wave
  reflection coefficients, and the numerical distance).
- Bies, D. A., Hansen, C. H., & Howard, C. Q. (2017). *Engineering Noise
  Control* (5th ed.). CRC Press. ISBN 978-1-4987-2405-0. Sections 5.2.3
  (spherical-wave ground reflection) and 5.3.5-5.3.7 (Fresnel number,
  Kurze-Anderson, thin- and thick-barrier diffraction and terrain shielding).
- Kurze, U. J., & Anderson, G. S. (1971). Sound attenuation by barriers.
  *Applied Acoustics*, 4(1), 35-53.
  [doi:10.1016/0003-682X(71)90024-7](https://doi.org/10.1016/0003-682X(71)90024-7).
- Hadden, W. J., & Pierce, A. D. (1981). Sound diffraction around screens and
  wedges for arbitrary point source locations. *Journal of the Acoustical
  Society of America*, 69(5), 1266-1276.
  [doi:10.1121/1.385809](https://doi.org/10.1121/1.385809).

---


<!-- source: docs/sound-power.md | canonical: https://jmrplens.github.io/phonometry/guides/sound-power/ -->

# Sound Power

Sound *pressure* depends on where you stand and on the room you stand in;
sound **power** does not. The sound power level `LW` is the total acoustic
energy per second a source radiates, referenced to `P0 = 1 pW`, and it is
the device-independent **emission** descriptor that goes on a datasheet,
feeds a room prediction (EN 12354) or is checked against a noise-emission
limit. This page covers the three routes phonometry implements to obtain it
and when to reach for each: an enveloping *pressure* surface in the field
(ISO 3744/3746), the diffuse field of a *reverberation room* (ISO 3741),
*intensity* scanning over a surface (ISO 9614-2), and, for the highest
accuracy, the precision grades in an *anechoic room* (ISO 3745) and by
precision *intensity* scanning (ISO 9614-3).

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/anim_power_two_rooms_dark.gif"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/anim_power_two_rooms.gif" alt="Animation: the same source in an anechoic room and in a reverberation room produces different microphone pressures, and the free-field and diffuse-field formulas converge to the same sound power level L_W" width="640" height="360" loading="lazy"></picture>

[Watch the high-resolution video (WebM)](https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/anim_power_two_rooms.webm)

## Choosing a method

All deliver the same quantity, a per-band `LW` and an A-weighted total
`LWA`, but under different environments, accuracy grades and practical
constraints.

| Method | Standard | Measured quantity | Environment | Accuracy grade | Use when |
| :--- | :--- | :--- | :--- | :--- | :--- |
| Enveloping surface | **ISO 3744** (engineering) / **ISO 3746** (survey) | Sound pressure on a hemisphere or box | Essentially free field over one or more reflecting planes | Grade 2 (`σR0 ≈ 1.5 dB`) / grade 3 (`≈ 3.0 dB`) | In situ or a large room; no special test facility available |
| Reverberation room | **ISO 3741** | Sound pressure in the diffuse field | Qualified hard-walled reverberation room | Grade 1 (precision) | Highest accuracy for steady, broadband sources in a lab |
| Intensity scanning | **ISO 9614-2** | Normal sound intensity scanned over a surface | Almost any, tolerant of steady extraneous noise | Grade 2 / 3 (from per-band field indicators) | On-site with background noise, or one machine among many |
| Anechoic room | **ISO 3745** | Sound pressure on a fixed microphone array | Qualified anechoic or hemi-anechoic room | Grade 1 (precision) | Reference-grade emission in a free-field laboratory |
| Precision intensity scanning | **ISO 9614-3** | Scanned normal intensity, tighter criteria | Almost any, tolerant of steady extraneous noise | Grade 1 (precision) | Precision on-site, with the ISO 9614-3 field-indicator checks |

The pressure methods correct the surface level for the room (`K2`) and for
background noise (`K1`); the reverberation method needs a *qualified* room
but reaches precision grade; intensity rejects steady background energy at
the cost of a two-microphone probe and a per-band validity check. The rest
of the page walks each in turn.

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/sound_power_methods_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/sound_power_methods.svg" alt="The three sound power routes side by side: an enveloping pressure surface over a reflecting plane (ISO 3744/3746), a source in a reverberation room sampled by microphones (ISO 3741) and an intensity probe scanning a surface around the source (ISO 9614-2)" width="92%"></picture>

<details>
<summary>Show the code for this figure</summary>

```python
import matplotlib.pyplot as plt
import numpy as np
from phonometry import emission

# One steady source (octave-band LW below) determined by three routes.
freqs = np.array([125.0, 250.0, 500.0, 1000.0, 2000.0, 4000.0])
lw_true = np.array([85.0, 88.0, 90.0, 89.0, 86.0, 82.0])

# ISO 3744: SPL at 10 positions on a hemisphere, r = 2 m (10 lg(S/S0) = 14 dB).
pres = emission.sound_power_pressure(np.tile(lw_true - 14.0, (10, 1)),
                                     "hemisphere", radius=2.0,
                                     frequencies=freqs)
# ISO 9614-2: uniform normal intensity scanned over six 0.5 m2 segments.
i_n = np.tile(10.0 ** (lw_true / 10.0) * 1e-12 / 3.0, (6, 1))
inten = emission.sound_power_intensity(i_n, np.full(6, 0.5),
                                       frequencies=freqs, band_type="octave")
# ISO 3741: comparison against a reference source of known LW = 84 dB per band.
comp = emission.sound_power_comparison(lw_true - 20.0, np.full(6, 64.0),
                                       np.full(6, 84.0), frequencies=freqs)

fig, ax = plt.subplots()
for res, style, ms, label in ((pres, "-o", 11, "pressure (ISO 3744)"),
                              (inten, "--s", 8, "intensity (ISO 9614-2)"),
                              (comp, ":^", 5, "reference source (ISO 3741)")):
    ax.semilogx(freqs, res.sound_power_level, style, markersize=ms,
                label=f"{label}: LWA = {res.sound_power_level_a:.1f} dB")
ax.set(xlabel="Frequency [Hz]", ylabel="Sound power level LW [dB]")
ax.legend()
plt.show()
```

</details>

### A decision path

The table compresses into a short sequence of questions (ISO 3740 dedicates
its Table 3 and Annex D to exactly this decision). Work through them in
order; the first match names the standard.

1. **What is the number for?** A datasheet declaration or a limit check
   normally asks for engineering grade (grade 2, the preferred grade for
   noise declarations); a reference source, a product ranking or a dispute
   calls for precision (grade 1); a first walk-through of a noisy plant
   tolerates survey grade (grade 3). Grade 1 exists only in a qualified
   laboratory room (ISO 3741, ISO 3745) or via the precision intensity
   methods (ISO 9614-1 at discrete points, ISO 9614-3 by scanning).
2. **Can the source travel to a laboratory?** ISO 3741 wants the source
   small next to the room (volume no more than about 2 % of the room volume)
   and its noise steady; ISO 3745 wants it inside a qualified anechoic or
   hemi-anechoic room with a characteristic dimension below half the
   measurement radius, and it is the route that also yields directivity. A
   machine bolted to its foundation rules both out and leaves the in-situ
   methods.
3. **How quiet and how dry is the site?** ISO 3744 needs the background at
   least 6 dB below the source (preferably more than 15 dB) and
   `K2 ≤ 4 dB`. If only a
   3 dB margin or `K2 ≤ 7 dB` can be met, the same microphones and formulae
   degrade gracefully to ISO 3746 at survey grade.
4. **Is the background the problem?** When neighbouring machines cannot be
   switched off, or the margin is outright negative, the pressure methods
   are out. Intensity scanning (ISO 9614-2, or ISO 9614-3 for grade 1)
   tolerates steady extraneous noise even some 10 dB *above* the source,
   because only the net energy flux through the surface counts; the
   per-band field indicators then decide the grade actually achieved.

### What the accuracy grades mean

The grade is a claim about **reproducibility**: `σR0` is the standard
deviation you would see if different laboratories measured the same source,
each following the standard correctly. Typical A-weighted values are
`σR0 ≈ 0.5 dB` for grade 1 (ISO 3741), `1.5 dB` for grade 2 (ISO 3744,
ISO 9614-2) and `3 dB` or more for grade 3 (larger still when `K2` is
large or the spectrum is tonal). Per-band values are larger at the
spectrum edges. The `uncertainty` field of the pressure-method results
(enveloping surface and anechoic) is the expanded uncertainty
`U = 2·σtot` (95 % coverage), where
`σtot = √(σR0² + σomc²)` also folds in the operating/mounting instability
`σomc` that you estimate and pass in; the grade only bounds the method's
share of the budget.

In practice: a grade-2 `LWA` of 92.4 dB carries `U ≈ 3 dB`, so two grade-2
results 2 dB apart are statistically indistinguishable, and checking that
same source against a 93 dB limit is a coin flip. Choose the grade from the
decision the number has to support, not from the facility that happens to
be free.

## 1. Enveloping surface, sound pressure (ISO 3744 / ISO 3746)

Place the source on a reflecting plane and imagine a **measurement surface**
of area `S` wrapping it: a hemisphere for a compact source, a box (right
parallelepiped) for a large or elongated one. Sample the sound pressure
level at an array of microphone positions on that surface, energy-average
them, and the sound power follows because a diffuse-enough surface captures
all the radiated energy:

$$
\bar{L}_p = 10 \log_{10}\left( \frac{1}{N_M} \sum_i 10^{L_{pi}/10} \right), \qquad
L_W = \bar{L}_p - K_1 - K_2 + 10 \log_{10}\frac{S}{S_0},\quad S_0 = 1\ \text{m}^2 .
$$

Two corrections clean up the surface level. The **background-noise
correction** removes the energy that would have been there with the source
switched off, from the margin `ΔLp` between source-on and background levels,

$$
K_1 = -10 \log_{10}\left( 1 - 10^{-\Delta L_p/10} \right),
$$

and the **environmental correction** removes the reverberant build-up of the
test room from its equivalent absorption area `A`,

$$
K_2 = 10 \log_{10}\left( 1 + \frac{4 S}{A} \right).
$$

The surface area is a closed form of the geometry: a hemisphere is
`S = 2πr²` over one reflecting plane (halved and quartered for two and three
planes), and a one-plane box is `S = 4(ab + bc + ca)` with
`a = 0.5·l1 + d`, `b = 0.5·l2 + d`, `c = l3 + d` for measurement distance
`d`. ISO 3746 (survey) shares every formula but is coarser: fewer
microphone positions, a 3 dB background criterion instead of 6 dB, and
validity up to `K2 ≤ 7 dB` instead of 4 dB.

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/diagram_sound_power_surfaces_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/diagram_sound_power_surfaces.svg" alt="Measurement surfaces of ISO 3744: a hemisphere of radius r enveloping a compact source on a reflecting plane, and a right parallelepiped (box) at measurement distance d around a large source, both with microphone positions marked" width="88%"></picture>

```python
import numpy as np
from phonometry import emission

# Octave-band SPL (dB) at the 10 hemisphere positions of ISO 3744 (Annex B),
# with the source running, plus the background spectrum with it switched off.
freqs = np.array([63, 125, 250, 500, 1000, 2000, 4000, 8000])
base = np.array([70.0, 74.0, 78.0, 80.0, 79.0, 76.0, 72.0, 66.0])
rng = np.random.default_rng(0)
levels = base + rng.normal(0.0, 0.5, size=(10, 8))     # (positions, bands)
background = np.full((10, 8), 55.0)

# ISO 3744 Annex B microphone coordinates on a radius-1.5 m hemisphere.
mic_xyz = emission.measurement_positions("hemisphere", radius=1.5, reflecting_planes=1)
print(mic_xyz.shape)                                    # (10, 3)

res = emission.sound_power_pressure(
    levels, "hemisphere", radius=1.5, reflecting_planes=1,
    background_levels=background, frequencies=freqs,
    reverberation_time=0.6, volume=300.0,          # room data -> K2
)
print(round(res.surface_area, 2))                       # 14.14 m^2 (= 2*pi*1.5^2)
print(round(float(res.environmental_correction[0]), 2)) # K2 = 2.32 dB
print(round(res.sound_power_level_a, 1))                # LWA = 92.4 dB
print(round(res.uncertainty, 1))                        # U = 3.0 dB (2*sigma_R0)
print(np.round(res.sound_power_level, 1))               # per-band LW

res.plot()   # sound power level bars per band, LWA in the title (needs matplotlib)
```

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/sound_power_pressure_result_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/sound_power_pressure_result.svg" alt="The enveloping-surface sound power level spectrum of the ISO 3744 hemisphere example, one bar per octave band from 63 Hz to 8 kHz peaking near 500 Hz, with the A-weighted total of 92.4 dB(A) in the title" width="88%"></picture>

*One bar per band: the energy-averaged surface pressure minus the background
(`K1`) and environmental (`K2`) corrections plus the surface term
`10 lg(S/S0)` gives `LW(f)`, and the A-weighted energy sum across bands gives
the single-number `LWA` in the title.*

<details>
<summary>Show the code for this figure</summary>

```python
import matplotlib.pyplot as plt
import numpy as np
from phonometry import emission

# Octave-band SPL (dB) at the 10 hemisphere positions of ISO 3744 (Annex B),
# with the source running, plus the background spectrum with it switched off.
freqs = np.array([63, 125, 250, 500, 1000, 2000, 4000, 8000])
base = np.array([70.0, 74.0, 78.0, 80.0, 79.0, 76.0, 72.0, 66.0])
rng = np.random.default_rng(0)
levels = base + rng.normal(0.0, 0.5, size=(10, 8))     # (positions, bands)
background = np.full((10, 8), 55.0)
res = emission.sound_power_pressure(
    levels, "hemisphere", radius=1.5, reflecting_planes=1,
    background_levels=background, frequencies=freqs,
    reverberation_time=0.6, volume=300.0,          # room data -> K2
)

# res is the SoundPowerResult computed above. One line:
res.plot()
plt.show()

# By hand: a bar spectrum of LW with the A-weighted total in the title.
freqs = res.frequencies
positions = np.arange(freqs.size)
fig, ax = plt.subplots()
ax.bar(positions, res.sound_power_level, width=0.7, color="#1f77b4")
ax.set_xticks(positions)
ax.set_xticklabels([f"{f:g}" for f in freqs], rotation=45, ha="right")
ax.set_xlabel("Frequency [Hz]")
ax.set_ylabel("Sound power level LW [dB]")
ax.set_title(
    f"Enveloping-surface sound power (ISO 3744)  "
    f"LWA = {res.sound_power_level_a:.1f} dB(A)")
plt.show()
```

</details>

The A-weighted total `LWA` is combined from the band powers with the ISO 3744
Annex E A-weighting corrections, so it needs `frequencies`. Passing the room
data (`reverberation_time` + `volume`, or `absorption_area`, or
`mean_absorption_coefficient` + `room_surface`) enables `K2`; omit it and the
field is treated as free (`K2 = 0`). If the background margin drops below the
grade criterion or `K2` exceeds the validity limit, a `SoundPowerWarning`
flags that the levels are upper bounds; the determination still returns.

### K1 and K2 pitfalls

Both corrections subtract energy from the surface level, so overestimating
either one understates the emission. That is why the standards cap them, and
why most disputes over an enveloping-surface result trace back to one of
these habits:

- **K1 has a cliff, not a slope.** At a 15 dB margin the correction is a
  negligible 0.14 dB; at the 6 dB engineering criterion it is already
  1.26 dB, the largest value the grade accepts. Below the criterion the
  standard does not let the formula run on: `K1` is capped and the result is
  reported as an upper bound. Never extrapolate the subtraction into a
  smaller margin; raise the margin (quieter site, closer surface) or switch
  to the intensity method.
- **K1 assumes a stationary background.** The source-off reading must be
  taken at the same positions with the room in the same state, and the
  background energy must be the same during both readings. A ventilation
  system that cycles or a vehicle passing during either reading invalidates
  the pair; the energy subtraction also assumes source and background are
  incoherent, which holds for unrelated noise but not for the source's own
  reflections.
- **K2 removes the average room build-up, not discrete reflections.** A
  nearby wall, a trolley or another machine just outside the surface adds a
  specular contribution concentrated at a few microphones. That imbalance
  shows up in the apparent directivity index `DIi*`, and no room-average
  correction can remove it: move the surface, remove the reflector or treat
  it with absorption.
- **K2 is only as good as `A`.** With `A` from Sabine (`0.16·V/T`), errors
  in the reverberation time or the volume propagate directly. At the
  `K2 = 4 dB` validity limit about 60 % of the measured energy is room, not
  source, and a 20 % error in `A` still moves `LW` by about 0.5 dB. Prefer a
  measured `T60` over a guessed absorption coefficient, and keep the
  measurement distance small enough that `K2` stays well under the limit.

### `sound_power_pressure()` parameters

| Parameter | Type | Units | Range / default | Notes |
| :--- | :--- | :--- | :--- | :--- |
| `levels_positions` | 2D array | dB | `(NM, NB)` | One row per position, one column per band (or a single A-weighted column) |
| `surface` | str | — | `'hemisphere'` / `'box'` | Measurement-surface shape |
| `radius` | float | m | > 0 (hemisphere) | Hemisphere radius `r` |
| `dimensions` | (float, float, float) | m | > 0 (box) | Reference-box `(l1, l2, l3)` |
| `distance` | float | m | > 0 (box) | Measurement distance `d` |
| `reflecting_planes` | int | — | `1` / `2` / `3`, default `1` | Halves/quarters the hemisphere area |
| `background_levels` | 2D array or spectrum | dB | `(NM, NB)`, or `(NB,)` / `(1, NB)` | Enables `K1`; a single spectrum broadcasts to every position |
| `frequencies` | 1D array | Hz | nominal band centres | Enables `LWA` (Annex E) |
| `absorption_area` | float or 1D array | m² | > 0 | `A` for `K2` (direct); per-band array → per-band `K2` |
| `reverberation_time`, `volume` | float/array, float | s, m³ | > 0 | `A = 0.16 V/T` for `K2`; per-band `T` → per-band `K2` |
| `mean_absorption_coefficient`, `room_surface` | float/array, float | —, m² | `(0,1]`, > 0 | `A = α·Sv` (Eq. A.7); per-band `α` → per-band `K2` |
| `grade` | str | — | `'engineering'` (default) / `'survey'` | ISO 3744 vs ISO 3746 |
| `omc_uncertainty` | float | dB | default `0.0` | `σomc`, operating/mounting instability, folded into `U` |

Returns a `SoundPowerResult`: `sound_power_level` (per-band `LW`),
`surface_pressure_level` (`Lp` after K1/K2), `mean_pressure_level`,
`background_correction`/`environmental_correction` (`K1`/`K2`),
`directivity_index` (apparent `DIi*` per microphone position **and** frequency
band, shape `(NM, NB)`; ISO 3744 clause 8.6), `surface_area`,
`sound_power_level_a` (`LWA`), `uncertainty` (expanded, 95 %) and `grade`.
`measurement_positions('hemisphere', radius=…, reflecting_planes=…, tones=…,
grade=…)` returns the normative `(N, 3)` microphone coordinates (Table B.1 for
tonal sources, B.2 for broadband).

## 2. Reverberation room, precision grade (ISO 3741)

In a qualified hard-walled **reverberation room** the field is diffuse, so a
handful of microphones sample the whole radiated energy and the method
reaches grade 1. The sound power comes from the mean room level `Lp(ST)`,
the Sabine absorption area `A = (55.26/c)·(V/T60)` and a chain of small
corrections (ISO 3741 Eq. 20):

$$
L_W = \bar{L}_p + 10 \log_{10}\frac{A}{A_0} + 4.34\ \frac{A}{S}
      + 10 \log_{10}\left( 1 + \frac{S c}{8 V f} \right) + C_1 + C_2 - 6 .
$$

The bracketed term is the **Waterhouse correction**: near the room
boundaries the sound energy density is higher than in the interior, and
this term (which vanishes as frequency grows) restores the energy the
interior microphones miss. `C1` (reference-quantity) and `C2`
(radiation-impedance) carry the result to the reference meteorological
conditions of 23 °C and 101.325 kPa,

$$
C_1 = -10 \log_{10}\frac{p_s}{p_{s0}} + 5 \log_{10}\frac{273.15 + \theta}{314}, \qquad
C_2 = -10 \log_{10}\frac{p_s}{p_{s0}} + 15 \log_{10}\frac{273.15 + \theta}{296},
$$

with the speed of sound `c = 20.05·√(273 + θ)`. The **comparison method**
replaces the absorption-area, Waterhouse and `C1` terms by a reference sound
source of known power `LW(RSS)` measured in the same room, so the room need
not be characterised: `LW = LW(RSS) + (Lp(ST) − Lp(RSS) + C2)`.

```python
import numpy as np
from phonometry import emission

# One-third-octave mean room SPL (dB), 100 Hz - 10 kHz, and the room's T60.
freqs = np.array([100, 125, 160, 200, 250, 315, 400, 500, 630, 800, 1000,
                  1250, 1600, 2000, 2500, 3150, 4000, 5000, 6300, 8000, 10000],
                 dtype=float)
lp = np.linspace(80.0, 70.0, freqs.size)
t60 = np.full(freqs.size, 2.0)

rev = emission.sound_power_reverberation(
    lp, t60, volume=200.0, surface_area=220.0, frequencies=freqs,
    temperature=20.0, static_pressure=101.0,
)
print(round(rev.speed_of_sound, 1))                     # c = 343.2 m/s
print(round(float(rev.absorption_area[0]), 1))          # A = 16.1 m^2 at 100 Hz
print(round(float(rev.waterhouse_correction[0]), 2))    # 1.68 dB at 100 Hz
print(round(float(rev.sound_power_level[0]), 1))        # LW = 87.9 dB
print(round(rev.sound_power_level_a, 1))                # LWA = 92.1 dB

# Comparison method: a reference source of known LW measured at the same spots.
lw_rss = np.full(freqs.size, 85.0)
lp_rss = np.linspace(78.0, 69.0, freqs.size)
cmp = emission.sound_power_comparison(lp, lp_rss, lw_rss, frequencies=freqs, temperature=20.0)
print(round(float(cmp.sound_power_level[0]), 1), cmp.method)   # 86.9 comparison

rev.plot()   # reverberation-room LW spectrum, LWA in the title (needs matplotlib)
```

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/sound_power_reverberation_result_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/sound_power_reverberation_result.svg" alt="The reverberation-room sound power level spectrum of the ISO 3741 example, one bar per one-third-octave band from 100 Hz to 10 kHz falling gently with frequency, with the A-weighted total of 92.1 dB(A) in the title" width="88%"></picture>

*The mean room level carried through the absorption-area, Waterhouse and
meteorological terms of Eq. 20 gives the one-third-octave `LW(f)`, and the
A-weighted energy sum across the 21 bands gives the `LWA` in the title.*

<details>
<summary>Show the code for this figure</summary>

```python
import matplotlib.pyplot as plt
import numpy as np
from phonometry import emission

# One-third-octave mean room SPL (dB), 100 Hz - 10 kHz, and the room's T60.
freqs = np.array([100, 125, 160, 200, 250, 315, 400, 500, 630, 800, 1000,
                  1250, 1600, 2000, 2500, 3150, 4000, 5000, 6300, 8000, 10000],
                 dtype=float)
lp = np.linspace(80.0, 70.0, freqs.size)
t60 = np.full(freqs.size, 2.0)
rev = emission.sound_power_reverberation(
    lp, t60, volume=200.0, surface_area=220.0, frequencies=freqs,
    temperature=20.0, static_pressure=101.0,
)

# rev is the ReverberationSoundPowerResult computed above. One line:
rev.plot()
plt.show()

# By hand: a bar spectrum of LW with the A-weighted total in the title.
freqs = rev.frequencies
positions = np.arange(freqs.size)
fig, ax = plt.subplots()
ax.bar(positions, rev.sound_power_level, width=0.7, color="#1f77b4")
ax.set_xticks(positions)
ax.set_xticklabels([f"{f:g}" for f in freqs], rotation=45, ha="right")
ax.set_xlabel("Frequency [Hz]")
ax.set_ylabel("Sound power level LW [dB]")
ax.set_title(
    f"Reverberation-room sound power (ISO 3741)  "
    f"LWA = {rev.sound_power_level_a:.1f} dB(A)")
plt.show()
```

</details>

`levels` may be a 1D mean spectrum or a 2D `(NM, NB)` array averaged over
positions. When the room volume, its reverberation time or the microphone
count fail an ISO 3741 qualification criterion (Table 1 minimum volume, the
`V/S` reverberation floor, fewer than 6 positions, or an inter-position
spread above 1.5 dB), an advisory `SoundPowerWarning` is emitted and the
result still returns.

### `sound_power_reverberation()` parameters

| Parameter | Type | Units | Range / default | Notes |
| :--- | :--- | :--- | :--- | :--- |
| `levels` | 1D or 2D array | dB | per band, or `(NM, NB)` | Mean room SPL; 2D is energy-averaged over positions |
| `t60` | float or 1D array | s | > 0 | Room reverberation time (scalar broadcasts) |
| `volume` | float | m³ | > 0 | Room volume `V` |
| `surface_area` | float | m² | > 0 | Total room surface `S` (Waterhouse, `A/S`) |
| `frequencies` | 1D array | Hz | one per band | Required (Waterhouse needs `f`); enables `LWA` |
| `background_levels` | 1D or 2D array | dB | matches `levels` | `K1i` per microphone position (Eq. 14/15, before the Eq. 16 average; frequency-dependent criterion) |
| `temperature` | float | °C | default `23.0` | Sets `c`, `C1`, `C2` |
| `static_pressure` | float | kPa | default `101.325` | Sets `C1`, `C2` |

`sound_power_comparison(levels, levels_ref, lw_ref, *, frequencies=None,
background_levels=…, background_levels_ref=…, temperature=23.0,
static_pressure=101.325)` takes the same room levels plus the reference
source's levels and known power.

| Parameter | Type | Units | Range / default | Notes |
| :--- | :--- | :--- | :--- | :--- |
| `levels_ref` | 1D or 2D array | dB | matches `levels` | Mean room SPL with the reference source (RSS) running |
| `lw_ref` | 1D array | dB | per band | Known sound power `LW(RSS)` of the reference source |
| `background_levels` | 1D or 2D array | dB | matches `levels` | Background for the test source; per-band `K1` on `Lp(ST)` |
| `background_levels_ref` | 1D or 2D array | dB | matches `levels_ref` | Background for the **reference** source; per-band `K1` on `Lp(RSS)` |

`background_levels_ref` background-corrects the reference-source room level
`Lp(RSS)` exactly as `background_levels` does for the test source; both need
`frequencies` (the ISO 3741 criterion is frequency-dependent). Both return a
`ReverberationSoundPowerResult`
(`sound_power_level`, `mean_pressure_level`, `absorption_area`,
`waterhouse_correction`, `background_correction`, `c1`, `c2`,
`speed_of_sound`, `sound_power_level_a`, `method`; the absorption/Waterhouse/`c1`
fields are `NaN` for the comparison method).

## 3. Intensity scanning (ISO 9614-2)

Sound **intensity** is the net energy flux, so it distinguishes energy
*leaving* the source from steady energy merely passing through the surface,
which is why the intensity method tolerates background noise that would
defeat the pressure methods. A p-p probe (see the
[Sound Intensity guide](https://jmrplens.github.io/phonometry/guides/intensity/)) is swept continuously over each of
`N` segments of a surface enclosing the source, reporting the segment-averaged
signed normal intensity `<In,i>`.

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/diagram_pp_probe_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/diagram_pp_probe.svg" alt="A two-microphone p-p sound intensity probe: two pressure microphones separated by a spacer, from which the pressure gradient and hence the normal intensity are estimated" width="70%"></picture>

The partial powers sum to the total:

$$
P_i = \langle I_{n,i} \rangle\ S_i, \qquad P = \sum_i P_i, \qquad
L_W = 10 \log_{10}\frac{P}{P_0},\quad P_0 = 1\ \text{pW} .
$$

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/anim_intensity_scan_power_dark.gif"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/anim_intensity_scan_power.gif" alt="Animation: a p-p probe traces the serpentine scan over the top face of the measurement box while the normal-intensity arrows appear behind it, and the partial powers of the five faces accumulate into the sound power level L_W" width="640" height="360" loading="lazy"></picture>

[Watch the high-resolution video (WebM)](https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/anim_intensity_scan_power.webm)

A band in which `P < 0` (net inflow, from a stronger source outside the
surface) is **not determinable** and reported as `NaN`. Two normative field
indicators qualify each band. The **surface pressure-intensity indicator**
`FpI` measures how reactive the field is, and the **negative-partial-power
indicator** `F+/-` measures how much energy circulates in and out:

$$
F_{pI} = [L_p] - L_W + 10 \log_{10}\frac{S}{S_0}, \qquad
F_{+/-} = 10 \log_{10}\frac{\sum_i \lvert P_i \rvert}{\lvert \sum_i P_i \rvert} .
$$

The probe's **dynamic capability** `Ld = δpI0 − K` (pressure-residual
intensity index minus the bias factor `K`, 10 dB for grade 2 and 7 dB for
grade 3) must exceed `FpI` (criterion 1); `F+/- ≤ 3 dB` is criterion 2
(mandatory for grade 2); and the two repeated sweeps must agree within the
Table 2 limit `s` per segment (criterion 3). A band is **engineering** grade
when criteria 1, 2 and 3 hold, **survey** when 1 and 3 hold, else `none`.
An A-weighted total additionally omits the bands failing criteria 1 and/or 2
(clause 10.6 b); the result flags them in `a_weighting_omitted_bands`.

```python
import numpy as np
from phonometry import emission

# 6 surface segments x 6 octave bands: signed normal intensity (W/m^2) from two
# repeated sweeps, the segment areas, and the per-segment surface SPL (dB).
freqs = np.array([125, 250, 500, 1000, 2000, 4000], dtype=float)
areas = np.full(6, 0.5)                                 # 0.5 m^2 per segment
rng = np.random.default_rng(0)
scan1 = np.abs(rng.normal(1e-4, 2e-5, size=(6, 6)))     # (segments, bands)
scan2 = scan1 * (1.0 + rng.normal(0.0, 0.02, size=(6, 6)))
pressure = np.full((6, 6), 80.0)

res = emission.sound_power_intensity(
    scan1, areas, normal_intensity_2=scan2, pressure_levels=pressure,
    pressure_residual_index=12.0, frequencies=freqs,
    band_type="octave", grade="engineering",
)
print(np.round(res.sound_power_level, 1))               # per-band LW
print(round(res.sound_power_level_a, 1))                # LWA, determinable + qualified bands
print(round(float(res.dynamic_capability_index[0]), 1)) # Ld = 12 - 10 = 2.0 dB
print(round(float(res.surface_pressure_intensity_index[0]), 2))   # FpI
print(list(res.achieved_grade))                         # per-band grade

res.plot()   # LW spectrum; non-positive (undeterminable) bands hatched (needs matplotlib)
```

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/sound_power_intensity_result_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/sound_power_intensity_result.svg" alt="The intensity-scanning sound power level spectrum of the ISO 9614-2 example, one bar per octave band from 125 Hz to 4 kHz all near 85 dB, with the A-weighted total of 90.9 dB(A) in the title" width="88%"></picture>

*The partial powers `<In,i>·Si` of the six segments sum to each band's `LW`;
every band here nets positive power and passes the field-indicator criteria at
engineering grade, so all six bars stand, and the A-weighted total of
90.9 dB(A) heads the title.*

<details>
<summary>Show the code for this figure</summary>

```python
import matplotlib.pyplot as plt
import numpy as np
from phonometry import emission

# 6 surface segments x 6 octave bands: signed normal intensity (W/m^2) from two
# repeated sweeps, the segment areas, and the per-segment surface SPL (dB).
freqs = np.array([125, 250, 500, 1000, 2000, 4000], dtype=float)
areas = np.full(6, 0.5)                                 # 0.5 m^2 per segment
rng = np.random.default_rng(0)
scan1 = np.abs(rng.normal(1e-4, 2e-5, size=(6, 6)))     # (segments, bands)
scan2 = scan1 * (1.0 + rng.normal(0.0, 0.02, size=(6, 6)))
pressure = np.full((6, 6), 80.0)
res = emission.sound_power_intensity(
    scan1, areas, normal_intensity_2=scan2, pressure_levels=pressure,
    pressure_residual_index=12.0, frequencies=freqs,
    band_type="octave", grade="engineering",
)

# res is the SoundPowerIntensityResult computed above. One line:
res.plot()
plt.show()

# By hand: a bar spectrum of LW with the A-weighted total in the title.
freqs = res.frequencies
positions = np.arange(freqs.size)
fig, ax = plt.subplots()
ax.bar(positions, res.sound_power_level, width=0.7, color="#1f77b4")
ax.set_xticks(positions)
ax.set_xticklabels([f"{f:g}" for f in freqs], rotation=45, ha="right")
ax.set_xlabel("Frequency [Hz]")
ax.set_ylabel("Sound power level LW [dB]")
ax.set_title(
    f"Intensity-scanning sound power (ISO 9614-2)  "
    f"LWA = {res.sound_power_level_a:.1f} dB(A)")
plt.show()
```

</details>

Supplying `normal_intensity_2` (the second sweep) averages the two for the
partial powers and evaluates criterion 3; `pressure_levels` enables `FpI`;
`pressure_residual_index` (`δpI0`) plus a second sweep enables the per-band
achieved grade. The probe's finite-difference intensity has a
frequency-dependent bias handled in the [intensity guide](https://jmrplens.github.io/phonometry/guides/intensity/).

### `sound_power_intensity()` parameters

| Parameter | Type | Units | Range / default | Notes |
| :--- | :--- | :--- | :--- | :--- |
| `normal_intensity` | 2D array | W/m² | `(N_seg, N_bands)` | Signed segment-averaged normal intensity `<In,i>` (first sweep) |
| `areas` | 1D array | m² | > 0, `(N_seg,)` | Segment areas `Si` |
| `normal_intensity_2` | 2D array | W/m² | same shape | Second sweep → criterion 3 and averaging |
| `pressure_levels` | 2D array | dB | same shape | Segment SPL `Lpi` → `FpI` |
| `pressure_residual_index` | float or 1D array | dB | — | `δpI0` → `Ld` / criterion 1 |
| `frequencies` | 1D array | Hz | nominal centres | `LWA` and Table 2 limits |
| `band_type` | str | — | `'third'` (default) / `'octave'` | Table 2 lookup |
| `grade` | str | — | `'engineering'` (default) / `'survey'` | Selects `K` |
| `repeatability_limit` | float or 1D array | dB | default Table 2 | Override criterion-3 `s` |

Returns a `SoundPowerIntensityResult`: `partial_power`/`partial_power_level`
per segment and band, `sound_power`/`sound_power_level` (band total, `NaN`
where `negative_band`), `surface_pressure_intensity_index` (`FpI`),
`negative_partial_power_index` (`F+/-`), `repeatability`,
`dynamic_capability_index` (`Ld`), `achieved_grade`, `surface_area`,
`sound_power_level_a` and `grade`.

## 4. Precision grade, anechoic room (ISO 3745)

When the highest accuracy is required, ISO 3745 measures sound power in a
qualified **anechoic** or **hemi-anechoic** room, where the free field lets a
fixed array of microphones sample the radiated sound pressure directly. It is the
grade-1 counterpart to the enveloping-surface method of Section 1, with
standardized microphone coordinates, a per-position background correction and an
explicit meteorological correction.

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/diagram_precision_anechoic_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/diagram_precision_anechoic.svg" alt="ISO 3745 precision sound power in an anechoic room: wedge-lined walls, the device under test at the centre and a hemispherical array of microphones at a fixed radius, with the sound power level formed from the surface-averaged pressure plus the area, background and meteorological corrections" width="92%"></picture>

**Sound power level (Clause 8).** The band sound power level is the
surface-averaged pressure level plus the surface term and the corrections:

$$
L_W = \overline{L_p} + 10\lg\frac{S}{S_0} + C_1 + C_2 + C_3,
$$

with $S = 4\pi r^2$ over the sphere or $S = 2\pi r^2$ over the hemisphere,
$S_0 = 1\ \text{m}^2$. $C_1$ and $C_2$ are the meteorological corrections
(reference and radiation-impedance terms); $C_3$ accounts for air absorption over
the measurement radius. The microphone positions are the standardized
unit-vector arrays of Tables D.1 (sphere), E.1 (hemisphere) and E.2 (hemisphere,
broadband).

```python
import numpy as np
from phonometry import emission

# The 40 standardized hemisphere positions (unit vectors scaled by the radius).
pos = emission.precision_positions("hemisphere", radius=1.0, count=40)
print(pos.shape)                      # (40, 3)

# Octave/third-octave band SPL (dB) at each of the 40 positions; here a uniform
# 74 dB in one band. The result carries S = 2*pi*r^2 and LW with C1+C2+C3.
levels = np.full((40, 1), 74.0)
res = emission.sound_power_anechoic(levels, "hemisphere", radius=1.0)
print(round(res.surface_area, 3))                 # 6.283  (2*pi*1^2)
print(np.round(res.sound_power_level, 2))         # [81.85]
```

**Background and meteorological corrections.** The $K_1$ background correction is
applied **per position** and floored where the signal-to-background difference is
small (Eq. 11); the meteorological correction is evaluated from the measured
temperature and static pressure.

```python
import numpy as np
from phonometry import emission

# K1 for a 6 dB signal-to-background difference in a <=200 Hz edge band: the
# floor is 1.26 dB (Eq. 11). Source and background levels are [positions, bands].
k1 = emission.precision_background_correction(
    np.array([[56.0]]), np.array([[50.0]]), np.array([200.0]))
print(round(float(k1[0, 0]), 4))      # 1.2563

# Meteorological corrections at the 23 C, 101.325 kPa reference (Eq. 16):
mc = emission.meteorological_corrections(23.0, 101.325)
print(round(mc.c1, 4), round(mc.c2, 4))   # -0.1282 0.0

# Expanded uncertainty (Clause 10.5 EXAMPLE): sigma_R0 = 0.5, sigma_omc = 2.0,
# k = 2 -> U = 4.1 dB.
print(round(emission.precision_uncertainty(0.5, 2.0, 2.0), 3))   # 4.123
```

Over several bands `sound_power_anechoic` returns a plottable
`PrecisionSoundPowerResult` carrying the per-band `LW` and the A-weighted total:

```python
import numpy as np
from phonometry import emission

# A mid-frequency-peaked machine measured over the 40-position hemisphere array
# (Annex E). levels_positions is the (40, NB) surface pressure spectrum: a base
# spectrum peaked near 1 kHz plus a small per-position spatial spread.
freqs = np.array([125, 250, 500, 1000, 2000, 4000, 8000], float)
base = 70.0 + 8.0 * np.exp(-(np.log2(freqs / 1000.0) ** 2) / 2.0)
rng = np.random.default_rng(7)
levels = base[None, :] + rng.normal(0.0, 1.0, (40, freqs.size))

result = emission.sound_power_anechoic(levels, "hemisphere", radius=1.0, frequencies=freqs)
print(round(result.sound_power_level_a, 1))   # 89.3
result.plot()   # LW spectrum, LWA in the title (needs matplotlib)
```

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/precision_anechoic_power_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/precision_anechoic_power.svg" alt="The precision sound power level spectrum of a mid-frequency-peaked machine measured over the ISO 3745 hemisphere array, one bar per band peaking near 1 kHz, with the A-weighted total of 89.3 dB(A) in the title" width="88%"></picture>

*One bar per band: the surface-averaged pressure plus the area, background and
meteorological corrections give `LW(f)`, and the A-weighted energy sum across
bands gives the single-number `LWA` in the title.*

<details>
<summary>Show the code for this figure</summary>

```python
import matplotlib.pyplot as plt
import numpy as np
from phonometry import emission

# A mid-frequency-peaked machine measured over the 40-position hemisphere array
# (Annex E). levels_positions is the (40, NB) surface pressure spectrum: a base
# spectrum peaked near 1 kHz plus a small per-position spatial spread.
freqs = np.array([125, 250, 500, 1000, 2000, 4000, 8000], float)
base = 70.0 + 8.0 * np.exp(-(np.log2(freqs / 1000.0) ** 2) / 2.0)
rng = np.random.default_rng(7)
levels = base[None, :] + rng.normal(0.0, 1.0, (40, freqs.size))
result = emission.sound_power_anechoic(levels, "hemisphere", radius=1.0, frequencies=freqs)

# result is the PrecisionSoundPowerResult computed above. One line:
result.plot()
plt.show()

# By hand: a bar spectrum of LW with the A-weighted total in the title.
freqs = result.frequencies
positions = np.arange(freqs.size)
fig, ax = plt.subplots()
ax.bar(positions, result.sound_power_level, width=0.7, color="#1f77b4")
ax.set_xticks(positions)
ax.set_xticklabels([f"{f:g}" for f in freqs], rotation=45, ha="right")
ax.set_xlabel("Frequency [Hz]")
ax.set_ylabel("Sound power level LW [dB]")
ax.set_title(
    f"Precision sound power (ISO 3745)  LWA = {result.sound_power_level_a:.1f} dB(A)")
plt.show()
```

</details>

## 5. Precision intensity scanning (ISO 9614-3)

ISO 9614-3 is the grade-1 scanning method: like ISO 9614-2 it integrates the
normal intensity over a surface enclosing the source, but with a continuous
scan, tighter field-indicator criteria and an explicit uncertainty budget.

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/diagram_intensity_scan_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/diagram_intensity_scan.svg" alt="ISO 9614-3 precision sound intensity scanning: a source enclosed by a measurement surface divided into segments, a two-microphone intensity probe scanned along a serpentine path over each segment, and the sound power formed by summing the normal intensity times segment area, subject to the field-indicator acceptance criteria" width="92%"></picture>

**Power and level (Clause 7).** The partial power of each segment is
$P_i = I_{n,i}\,S_i$; the total $P = \sum_i P_i$ gives
$L_W = 10\lg(P/P_0)$, $P_0 = 1\ \text{pW}$. A band whose net intensity is
negative (more power flowing in than out) is flagged not-applicable rather than
logged. The field indicators (temporal variability $F_T$, the signed and
unsigned pressure–intensity indicators, and the non-uniformity $F_S$) drive the
five acceptance criteria.

```python
import numpy as np
from phonometry import emission

# A fully enclosing surface with a uniform normal intensity In = W/S recovers
# the source power exactly: LW = 10*lg(W/P0). Here W = 100 uW -> 80 dB.
areas = np.array([0.5, 1.0, 0.25, 2.0])
w = 1.0e-4
i_n = np.full(areas.shape, w / float(areas.sum()))
res = emission.sound_power_intensity_precision(i_n, areas)
print(round(float(res.sound_power[0]), 6))          # 0.0001
print(round(float(res.sound_power_level[0]), 2))    # 80.0
```

Across several bands the result carries the per-band `LW` (`NaN` where the net
power is non-positive) and flags those bands `not_applicable`:

```python
import numpy as np
from phonometry import emission

# Four partial surfaces scanned over five one-third-octave bands. Each cell of
# partial_intensity is the signed normal intensity In_i (W/m^2); areas are the
# partial-surface areas Si. The 250 Hz band has net-negative power (a locally
# reactive field), so ISO 9614-3 flags it not-applicable (clause 9.2) -> NaN.
freqs = np.array([250, 500, 1000, 2000, 4000], float)
areas = np.array([0.5, 1.0, 0.75, 0.5])
base_intensity = np.array([2.0e-6, 8.0e-6, 2.0e-5, 1.0e-5, 3.0e-6])
partial_intensity = base_intensity[None, :] * np.array([1.0, 1.1, 0.9, 1.05])[:, None]
partial_intensity[:, 0] = [2.0e-6, -3.0e-6, -4.0e-6, -1.0e-6]   # net-negative band

result = emission.sound_power_intensity_precision(partial_intensity, areas, frequencies=freqs)
print(result.not_applicable_band.tolist())   # [True, False, False, False, False]
print(round(result.sound_power_level_a, 1))   # 80.6
result.plot()   # LW spectrum; the not-applicable band is hatched (needs matplotlib)
```

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/intensity_scan_power_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/intensity_scan_power.svg" alt="The precision intensity-scanning sound power level spectrum over five one-third-octave bands, four determinate bars and a hatched, greyed 250 Hz band flagged not-applicable because its net intensity is negative, with the A-weighted total of 80.6 dB(A) in the title" width="88%"></picture>

*The 250 Hz band nets negative (more energy flowing in than out), so ISO 9614-3
declares it not-applicable; the figure hatches and greys it while the four
determinate bands and the A-weighted total stand.*

<details>
<summary>Show the code for this figure</summary>

```python
import matplotlib.pyplot as plt
import numpy as np
from phonometry import emission

# Four partial surfaces scanned over five one-third-octave bands. Each cell of
# partial_intensity is the signed normal intensity In_i (W/m^2); areas are the
# partial-surface areas Si. The 250 Hz band has net-negative power (a locally
# reactive field), so ISO 9614-3 flags it not-applicable (clause 9.2) -> NaN.
freqs = np.array([250, 500, 1000, 2000, 4000], float)
areas = np.array([0.5, 1.0, 0.75, 0.5])
base_intensity = np.array([2.0e-6, 8.0e-6, 2.0e-5, 1.0e-5, 3.0e-6])
partial_intensity = base_intensity[None, :] * np.array([1.0, 1.1, 0.9, 1.05])[:, None]
partial_intensity[:, 0] = [2.0e-6, -3.0e-6, -4.0e-6, -1.0e-6]   # net-negative band
result = emission.sound_power_intensity_precision(partial_intensity, areas, frequencies=freqs)

# result is the PrecisionIntensityResult computed above. One line:
result.plot()
plt.show()

# By hand: determinate bands as LW bars; a not-applicable band (its LW is NaN)
# is flagged by a full-height greyed, hatched span rather than a zero-height bar.
freqs = result.frequencies
positions = np.arange(freqs.size)
neg = result.not_applicable_band
lw = np.nan_to_num(result.sound_power_level)
fig, ax = plt.subplots()
ax.bar(positions[~neg], lw[~neg], width=0.7, color="#1f77b4")
for pos in positions[neg]:
    ax.axvspan(pos - 0.35, pos + 0.35, facecolor="#888888", alpha=0.28,
               hatch="//", edgecolor="#888888")
ax.set_xticks(positions)
ax.set_xticklabels([f"{f:g}" for f in freqs], rotation=45, ha="right")
ax.set_xlabel("Frequency [Hz]")
ax.set_ylabel("Sound power level LW [dB]")
ax.set_title(
    f"Precision intensity scanning (ISO 9614-3)  "
    f"LWA = {result.sound_power_level_a:.1f} dB(A)")
plt.show()
```

</details>

## See also

- [Sound Intensity (p-p)](https://jmrplens.github.io/phonometry/guides/intensity/): the two-microphone probe, its
  finite-difference bias and the ISO 9614-1 field indicators behind the
  scanning method.
- [Room Acoustics](https://jmrplens.github.io/phonometry/guides/room-acoustics/): the reverberation time
  and equivalent absorption area (ISO 354) that feed `K2` and the ISO 3741
  absorption area; impact and airborne insulation.
- [Levels](https://jmrplens.github.io/phonometry/guides/levels/): energy averaging and the A-weighting behind `LWA`.
- [Theory](https://jmrplens.github.io/phonometry/reference/theory/environment-transport/): the Waterhouse, K1/K2 and C1/C2 derivations.
- API reference: [`emission.sound_power`](https://jmrplens.github.io/phonometry/reference/api/power/sound-power/), [`emission.sound_power_reverberation`](https://jmrplens.github.io/phonometry/reference/api/power/sound-power-reverberation/) and [`emission.sound_power_intensity`](https://jmrplens.github.io/phonometry/reference/api/power/sound-power-intensity/).

## References

- Fahy, F. J. (1995). *Sound intensity* (2nd ed.). E&FN Spon.
  ISBN 978-0-419-19810-9.
  [doi:10.4324/9780203475386](https://doi.org/10.4324/9780203475386).
  The monograph on sound energy flux: why intensity separates the energy
  leaving the source from steady energy passing through, behind the
  scanning methods of sections 3 and 5.
- Beranek, L. L., & Mellow, T. J. (2012). *Acoustics: Sound fields and
  transducers*. Academic Press. ISBN 978-0-12-391421-7.
  [doi:10.1016/C2011-0-05897-0](https://doi.org/10.1016/C2011-0-05897-0).
  Radiation and sound fields: the free-field and diffuse-field relations
  between pressure and power that the enveloping-surface and
  reverberation-room methods rest on.
- International Organization for Standardization. (2019). *Acoustics —
  Determination of sound power levels of noise sources — Guidelines for the
  use of basic standards* (ISO 3740:2019).
  [iso.org catalogue](https://www.iso.org/standard/45107.html).
  The selection guide behind "Choosing a method": grades, environments,
  source-size and background criteria for the whole family.
- International Organization for Standardization. (2010). *Acoustics —
  Determination of sound power levels and sound energy levels of noise
  sources using sound pressure — Precision methods for reverberation test
  rooms* (ISO 3741:2010).
  [iso.org catalogue](https://www.iso.org/standard/52053.html).
  The reverberation-room method of section 2.
- International Organization for Standardization. (2010). *Acoustics —
  Determination of sound power levels and sound energy levels of noise
  sources using sound pressure — Engineering methods for an essentially free
  field over a reflecting plane* (ISO 3744:2010).
  [iso.org catalogue](https://www.iso.org/standard/52055.html).
  The enveloping-surface method of section 1.
- International Organization for Standardization. (2012). *Acoustics —
  Determination of sound power levels and sound energy levels of noise
  sources using sound pressure — Precision methods for anechoic rooms and
  hemi-anechoic rooms* (ISO 3745:2012).
  [iso.org catalogue](https://www.iso.org/standard/45362.html).
  The precision anechoic-room method of section 4.

## Standards

ISO 3744:2010, *Acoustics — Determination of sound power levels
and sound energy levels of noise sources using sound pressure — Engineering
methods for an essentially free field over a reflecting plane*: the
enveloping-surface method: hemisphere and box surface areas, the `K1`/`K2`
corrections, the Annex B microphone positions and the Annex E A-weighting.
ISO 3746:2010, *… Survey method using an enveloping measurement surface over a
reflecting plane*: the survey grade sharing the same formulae with coarser
criteria. ISO 3741:2010, *… Precision methods for reverberation test rooms*:
the direct (Eq. 20) and comparison methods with the Waterhouse and
meteorological corrections and the Table 1 qualification criteria.
ISO 3745:2012, *… Precision methods for anechoic rooms and hemi-anechoic
rooms*: the Clause 8 power level, the per-position background correction
(Eq. 11), the meteorological corrections and the standardized microphone
arrays. ISO 9614-2:1996, *Acoustics — Determination of sound power levels of
noise sources using sound intensity — Part 2: Measurement by scanning* — the
partial powers, the `FpI` and `F+/-` field indicators and the grade criteria.
ISO 9614-3:2002, *… Part 3: Precision method for measurement by scanning*:
the grade-1 scanning method, its field indicators and the clause 9.2
not-applicable flagging.

---


<!-- source: docs/calibration.md | canonical: https://jmrplens.github.io/phonometry/guides/calibration/ -->

# Calibration and dBFS

phonometry can return results in physical **Sound Pressure Level (dB SPL)** or
digital **decibels relative to Full Scale (dBFS)**.

## Why calibrate? The theory

A digital recording only knows *numbers*: a full-scale sine wave is ±1.0
regardless of whether it was a whisper or a jet engine. To report physical
sound pressure levels the chain microphone → preamplifier → ADC must be
characterized by a single number, the **sensitivity factor** $S$, that
converts digital units into pascals:

$$
p(t) = S\ x(t) \qquad S = \frac{p_\text{ref}\cdot 10^{L_\text{cal}/20}}{\tilde{x}_\text{ref}}
$$

where $L_\text{cal}$ is the calibrator's level (typically 94 dB, i.e. 1 Pa),
$p_\text{ref} = 20\ \mu\text{Pa}$ and $\tilde{x}_\text{ref}$ is the RMS of
the recorded calibration tone in digital units. `sensitivity()` is
exactly that equation. The factor is valid as long as nothing in the chain
changes: touch the gain knob and you must recalibrate.

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/diagram_calibration_setup_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/diagram_calibration_setup.svg" alt="Calibration chain: sound calibrator coupled on the microphone, preamplifier, ADC and sensitivity producing pascals per digital unit" width="92%"></picture>

## Physical Calibration (Sound Level Meter)

To get accurate SPL measurements from a digital recording, you must first
calculate the sensitivity of your measurement chain using a reference tone
(e.g., 94 dB @ 1 kHz).

```mermaid
flowchart LR
    A["Calibrator tone\n94 dB @ 1 kHz\n(IEC 60942)"] --> B["Recording\ncalibrator_recording"]
    B --> C["sensitivity()"]
    C --> D["calibration_factor\n(digital units → Pa)"]
    D --> E["octave_filter / leq / laeq / ln_levels"]
    F["Measurement\nrecording"] --> E
    E --> G["Levels in dB SPL\n(re 20 µPa)"]
```

```python
import numpy as np
from phonometry import metrology

# 1. Record your 94 dB calibrator signal (1 kHz, 1 Pa RMS = 94 dB SPL)
fs = 48000
# calibrator_recording: your recorded 1 kHz calibrator tone (1 Pa RMS = 94 dB SPL).
#   Synthesized here so the guide runs; in a real measurement, record your calibrator.
calibrator_recording = np.sqrt(2) * np.sin(2 * np.pi * 1000 * np.arange(fs) / fs)
# recording: the mic capture you want to calibrate, same input chain (Pa after calibration).
#   Synthesized here; in a real measurement this is your recorded signal.
recording = 0.2 * np.sin(2 * np.pi * 1000 * np.arange(fs) / fs)

# 2. Calculate the sensitivity factor
calibration_factor = metrology.sensitivity(calibrator_recording, target_spl=94.0, fs=fs)

# 3. Apply calibration to your measurements
spl, freq = metrology.octave_filter(recording, fs, calibration_factor=calibration_factor)
# Now 'spl' values are in real-world dB SPL!
```

The same `calibration_factor` works across the whole library: `octave_filter`,
`OctaveFilterBank`, `leq`, `laeq` and `ln_levels`.

## Calibrator assumptions (IEC 60942)

`sensitivity` assumes the reference recording comes from an acoustic
calibrator as specified by **IEC 60942** (classes LS, 1 and 2):

- The default `target_spl=94.0` matches the common 94 dB @ 1 kHz calibrator
  output (the standard requires the principal level to be at least 90 dB re
  20 µPa; 94 dB and 114 dB are the usual choices).
- The resulting sensitivity inherits the calibrator's class tolerance (e.g.
  ±0.4 dB for a class 1 calibrator between 160 Hz and 1.25 kHz, IEC 60942
  Table 1) plus the RMS estimation error of your recording.
- IEC 60942 specifies the generated level as a 20 s average: record a few
  seconds of *stable* tone (excluding handling noise at the start/end) for the
  RMS estimate to converge.

### Automatic stability validation

When you pass the sample rate (and `validate=True`, the default),
`sensitivity(ref, fs=fs)` checks the recording the way
IEC 60942:2017 checks the calibrator itself (5.3.3): the *short-term level
fluctuation*, the absolute difference between each of the maximum and minimum
F-time-weighted levels and the mean level, must not exceed the Table 2 class 1
limit for the calibrator's nominal frequency (0.07 dB at and above 160 Hz, relaxed
to 0.10 dB below 160 Hz and 0.20 dB at or below 63 Hz, where the F
time-weighting itself ripples). Pass `frequency=` to select the right row for non-1 kHz
calibrators. A `CalibrationWarning` flags badly coupled microphones or handling
noise before they silently corrupt every calibrated level. The recording must
be at least 2 s long (1 s for the F-integrator to settle plus 1 s of settled
envelope); shorter recordings get a warning instead of an unreliable verdict.
Without `fs` the check is skipped. Override the limit with
`max_fluctuation_db` or disable with `validate=False`.

The check catches exactly what ruins field calibrations (a loose coupler,
wind, handling noise):

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/calibration_stability_dark.webp"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/calibration_stability.webp" alt="F-weighted level of a stable calibration tone versus a 3 percent amplitude-modulated one against the plus-minus 0.07 dB IEC 60942 class 1 limit" width="80%"></picture>

<details>
<summary>Show the code for this figure</summary>

```python
import matplotlib.pyplot as plt
import numpy as np
from phonometry import metrology

fs = 48000
t = np.arange(int(fs * 6.0)) / fs
stable = 0.5 * np.sin(2 * np.pi * 1000 * t)
# 3 % amplitude modulation at 2 Hz: ~0.14 dB of wobble, clearly over
unstable = stable * (1 + 0.03 * np.sin(2 * np.pi * 2.0 * t))

plt.figure(figsize=(9, 5))
skip = fs                     # discard the F-integrator attack (~8 tau)
for x, label in ((stable, "Stable tone (good coupling)"),
                 (unstable, "3% AM tone (loose coupling)")):
    env = metrology.time_weighting(x, fs, mode="fast")[skip:]
    level = 10 * np.log10(np.maximum(env, np.finfo(float).eps))
    plt.plot(t[skip:], level - level.mean(), label=label)
for lim in (0.07, -0.07):
    plt.axhline(lim, linestyle="--", color="gray")
plt.xlabel("Time [s]")
plt.ylabel("F-weighted level re mean [dB]")
plt.legend()
plt.show()
```

</details>

### `sensitivity()` parameters

| Parameter | Type / shape | Units | Range / default | Notes |
| :--- | :--- | :--- | :--- | :--- |
| `ref_signal` | 1D/2D array | digital units | non-empty, non-silent | Recording of the calibration tone only (trim handling noise) |
| `target_spl` | float | dB re 20 µPa | default `94.0` | The calibrator's nominal level (114 dB calibrators: pass `114.0`) |
| `ref_pressure` | float | Pa | default `2e-5` | Reference pressure p₀; rarely changed |
| `fs` | int, optional | Hz | > 0; default `None` | Required for the stability validation; omit to skip it |
| `validate` | bool | — | default `True` | Emit `CalibrationWarning` on unstable/short recordings |
| `max_fluctuation_db` | float, optional | dB | default `None` → Table 2 class 1 | Explicit override of the stability limit |
| `frequency` | float | Hz | default `1000.0` | Calibrator's nominal frequency; selects the IEC 60942 Table 2 row |
| `narrowband` | bool | — | default `False` | Estimate the tone with a coherent Goertzel detector near `frequency` (needs `fs`) instead of full-band RMS; rejects broadband hum/noise that otherwise inflates the RMS and shrinks every later level (~−0.44 dB at 20 dB SNR). Enable for noisy coupler recordings |

Returns the sensitivity factor (float) to pass as `calibration_factor=` to
`octave_filter`, `leq`, `laeq`, `ln_levels`, `lc_peak`, `sel` and the dose
functions.

## Field checks, laboratory verification and drift

Calibration lives at three time scales:

- **Every session: the field check.** Couple the calibrator and derive the
  sensitivity before each measurement series, and check it again at the end.
  Normative methods make the second check mandatory and use the pre/post
  difference as a validity gate (a common criterion invalidates the series
  when the two differ by more than 0.5 dB). Whatever the threshold, the
  difference is your drift bound for everything captured in between; carry
  it into the uncertainty budget rather than assuming zero.
- **Periodically: laboratory verification.** A field check only compares the
  chain against the calibrator; it cannot see an error the calibrator and
  meter share, and it says nothing about the response away from 1 kHz.
  IEC 61672-3 defines the periodic tests for the meter (weightings,
  level linearity and ballistics spot-checked against the class limits),
  and IEC 60942 the corresponding tests for the calibrator itself; typical
  laboratory intervals are one to two years.
- **Between checks: drift.** Microphone sensitivity moves with temperature,
  humidity and capsule aging; electronics with battery voltage. A healthy
  class 1 chain drifts a few hundredths of a dB over a session, which is
  why a pre/post difference of half a decibel signals damage rather than
  weather. The largest "drift" of all is a touched gain knob: the factor S
  is valid only while the chain stays exactly as calibrated.

One more class subtlety: tolerances chain. A class 1 measurement requires a
class 1 (or LS) calibrator *and* a class 1 meter; calibrating a class 1
chain with a class 2 calibrator silently downgrades every derived level to
class 2 accuracy, because the calibrator's wider level tolerance enters S
directly.

## Digital Analysis (dBFS)

If you are working with digital audio files (e.g., WAV, FLAC) and want to
analyze levels relative to Full Scale rather than physical pressure, you can use
the `dbfs=True` parameter.

In this mode:

* **0 dBFS** corresponds to a numeric signal level of 1.0 (RMS or Peak).
* `calibration_factor` does not apply (dBFS is relative to digital full scale).
* Useful for analyzing headroom, digital mastering, or normalized signals.

```python
import numpy as np
from phonometry import metrology

fs = 48000
# recording: the mic capture you want to calibrate, same input chain (Pa after calibration).
#   Synthesized here; in a real measurement this is your recorded signal.
recording = 0.2 * np.sin(2 * np.pi * 1000 * np.arange(fs) / fs)

# Assume 'recording' is normalized between -1.0 and 1.0
spl_dbfs, freq = metrology.octave_filter(recording, fs, dbfs=True)
# Results will be negative (e.g., -20 dBFS)
```

## RMS vs Peak Levels

phonometry supports two measurement modes to align with professional software
like BK:

- **RMS (`mode='rms'`)**: Energy-based level (standard).
- **Peak (`mode='peak'`)**: Absolute maximum value reached in the frame
  (Peak-holding).

```python
import numpy as np
from phonometry import metrology

fs = 48000
# recording: the mic capture you want to calibrate, same input chain (Pa after calibration).
#   Synthesized here; in a real measurement this is your recorded signal.
recording = 0.2 * np.sin(2 * np.pi * 1000 * np.arange(fs) / fs)

# Measure peak-holding levels for impact analysis
spl_peak, freq = metrology.octave_filter(recording, fs, mode='peak')
```

> [!NOTE]
> `mode='peak'` measures the absolute maximum of the **filtered** band signal,
> which includes the filter's onset transient (overshoot). Signals that start
> abruptly may read up to ~1 dB high. This is inherent to IIR band filters
> (an analog SLM behaves the same way), not a processing artifact.

## Integer audio input

Integer signals (e.g. int16 from `scipy.io.wavfile.read`) are converted to
float64 internally before any squaring, so calibration and level results are
identical whether you pass the raw integer array or a float conversion.

## References

- International Electrotechnical Commission. (2017). *Electroacoustics —
  Sound calibrators* (IEC 60942:2017).
  [IEC webstore](https://webstore.iec.ch/en/publication/30045).
  The calibrator classes, level tolerances and the short-term stability
  criterion `sensitivity()` applies to the reference recording.
- International Electrotechnical Commission. (2013). *Electroacoustics —
  Sound level meters — Part 3: Periodic tests* (IEC 61672-3:2013).
  [IEC webstore](https://webstore.iec.ch/en/publication/5710).
  The laboratory verification procedure behind the periodic checks
  recommended above.

## Standards

IEC 60942:2017, *Electroacoustics — Sound calibrators* — the
calibrator level and class assumptions behind `sensitivity()` (the 94 dB
principal level and the Table 1 class tolerances) and the short-term
level-fluctuation stability check of the reference recording (5.3.3, Table 2
class 1 limits per nominal frequency).

---


<!-- source: docs/data-qualification.md | canonical: https://jmrplens.github.io/phonometry/guides/data-qualification/ -->

# Data qualification: stationarity, level crossings and peaks (Bendat & Piersol)

Every average in this documentation - a [PSD](https://jmrplens.github.io/phonometry/guides/spectral-analysis/), a
[Leq](https://jmrplens.github.io/phonometry/guides/levels/), a
[GUM budget](gum-uncertainty.md) - assumes the record is
**stationary**: that the process generating it did not drift while it was
being measured. Bendat & Piersol devote Section 10.3 of *Random Data* to
qualifying records before analysis, and `phonometry.metrology` implements its
quantitative core: distribution-free **trend and stationarity tests** with
the book's own acceptance regions, and the **Rice statistics** - level
crossings, apparent frequency, peak rates and heights - that summarize what
a qualified Gaussian record looks like and flag one that is not.

## 1. The reverse arrangement test

Given a sequence of $N$ observations $x_1, \dots, x_N$ - parameter estimates,
segment levels, anything - count the pairs $i < j$ with $x_i > x_j$. Each
such pair is a **reverse arrangement**, and for independent observations of
one random variable their total $A$ has (B&P Eqs. (4.54)-(4.55))

$$
\mu_A = \frac{N(N-1)}{4}, \qquad
\sigma_A^2 = \frac{N(2N+5)(N-1)}{72},
$$

with no assumption about the distribution of the $x_i$. A monotonic trend
pushes $A$ to an extreme (0 for a rising sequence, $N(N-1)/2$ for a falling
one), so the hypothesis of *no trend* is accepted at significance $\alpha$
when $A$ falls inside a two-sided region - B&P Table A.6, whose
$\alpha = 0.05$ rows `trend_test` reproduces exactly and the conformance
suite pins. The book's Example 4.4 (twenty observations, $A = 86$, accepted
between 64 and 125) runs verbatim:

```python
from phonometry import trend_test

values = [5.2, 6.2, 3.7, 6.4, 3.9, 4.0, 3.9, 5.3, 4.0, 4.6,
          5.9, 6.5, 4.3, 5.7, 3.1, 5.6, 5.2, 3.9, 6.2, 5.0]

res = trend_test(values)                  # B&P Example 4.4
print(res.statistic, res.bounds)          # 86, (64, 125)
print(res.trend_free, round(res.p_value, 3))   # True, 0.586
res.plot()
```

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/trend_test_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/trend_test.svg" alt="Two sequences of twenty observations plotted against the sample index: Bendat and Piersol Example 4.4, which fluctuates around five with eighty-six reverse arrangements and is accepted as trend free, and the same fluctuations with an added rising drift climbing from about five to nine, whose thirty-eight reverse arrangements fall below the lower acceptance bound of sixty-four and are rejected as trended at the five percent level" width="82%"></picture>

<details>
<summary>Show the code for this figure</summary>

```python
import matplotlib.pyplot as plt
import numpy as np
from phonometry import trend_test

example = np.array([5.2, 6.2, 3.7, 6.4, 3.9, 4.0, 3.9, 5.3, 4.0, 4.6,
                    5.9, 6.5, 4.3, 5.7, 3.1, 5.6, 5.2, 3.9, 6.2, 5.0])
drifting = example + np.linspace(0.0, 4.0, example.size)   # rising drift

res_flat = trend_test(example)
res_drift = trend_test(drifting)

fig, ax = plt.subplots(figsize=(10, 6))
index = np.arange(1, example.size + 1)
ax.plot(index, res_flat.values, "o-",
        label=f"Example 4.4: A = {res_flat.statistic}, accepted")
ax.plot(index, res_drift.values, "s-",
        label=f"Rising drift: A = {res_drift.statistic}, rejected")
ax.set_xlabel("Sample index")
ax.set_ylabel("Sequence value")
ax.legend()
plt.show()
```

</details>

The p-value comes from the exact null distribution of $A$ (the inversion
counts of a random permutation), computed up to $N = 100$ - the range of
Table A.6 - and from the book's normal approximation beyond; the verdict
follows the book's tabulated region. `.plot()` draws the tested sequence
against its sample index with the count, the acceptance region and the
verdict in the legend (for `method="runs"` it also marks the sequence
median that classifies each value).

## 2. Stationarity of a record

The B&P Sec. 10.3.1.1 procedure turns the trend test into a stationarity
test for a single time history: divide the record into $N$ equal intervals
long enough to be independent, compute a **mean square value per interval**,
and test that sequence. Nothing needs to be known about the record's
bandwidth, averaging distribution or units, and the test works on mean
values, rms values or variances just as well (`statistic=`). A running
20 % gain drift - the book's Example 10.3 scenario - is caught immediately,
while the same noise without the drift passes:

```python
import numpy as np
from phonometry import stationarity_test

fs = 8192.0
n = 1 << 16
noise = np.random.default_rng(42).standard_normal(n)

res = stationarity_test(noise, fs)        # 20 segments, mean squares
print(res.stationary, res.count, res.bounds)   # True, 91, (64, 125)

drifting = noise * np.linspace(1.0, 1.2, n)    # +20 % gain ramp
res = stationarity_test(drifting, fs)
print(res.stationary, res.count)          # False, 7  (upward trend -> low A)
res.plot()
```

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/stationarity_test_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/stationarity_test.svg" alt="Twenty segment mean square values for two noise records: a steady record whose values fluctuate around one with a reverse arrangement count of ninety-one, accepted as stationary, and the same noise with a twenty percent gain ramp whose segment mean squares climb steadily to one point five, giving only seven reverse arrangements, rejected as nonstationary at the five percent level" width="82%"></picture>

<details>
<summary>Show the code for this figure</summary>

```python
import matplotlib.pyplot as plt
import numpy as np
from phonometry import stationarity_test

fs = 8192.0
n = 1 << 16
steady = np.random.default_rng(42).standard_normal(n)
ramp = np.random.default_rng(42).standard_normal(n) * np.linspace(1.0, 1.2, n)

res_steady = stationarity_test(steady, fs)
res_ramp = stationarity_test(ramp, fs)

fig, ax = plt.subplots(figsize=(10, 6))
index = np.arange(1, res_steady.n_segments + 1)
ax.plot(index, res_steady.segment_values, "o-",
        label=f"Steady noise: A = {res_steady.count}, accepted")
ax.plot(index, res_ramp.segment_values, "s-",
        label=f"+20 % gain ramp: A = {res_ramp.count}, rejected")
ax.set_xlabel("Segment index")
ax.set_ylabel("Segment mean square")
ax.legend()
plt.show()
```

</details>

The result records the per-segment sequence (`segment_values`,
`segment_times`), the count, the Table A.6 `bounds`, the exact `p_value`
and the `stationary` verdict; `.plot()` draws the sequence with the verdict
in the legend. The default of 20 segments matches the book's worked
examples - more segments resolve faster drifts but each interval must stay
long against the record's lowest frequencies for the values to be
independent.

Two caveats from the book are worth repeating. A record can be
nonstationary with a stationary mean square (a frequency glide, for
instance), so pass `statistic="mean"` or test band-filtered versions when
that matters; and the test needs the *trend* to be slow against the segment
length, or it dissolves into the random fluctuation of the segment values.

## 3. The runs test

The classical companion (tabulated in the third edition of *Random Data*;
the exact distribution is Wald & Wolfowitz 1940) classifies each value as
above or below the sequence median and counts **runs** of like
classification. A trend or slow drift produces few long runs; rapid
alternation produces too many. `method="runs"` applies it with the exact
conditional distribution at any $N$ - for twenty values split 10/10 the
acceptance region at $\alpha = 0.05$ is the classical $(6, 15]$:

```python
import numpy as np
from phonometry import trend_test

rng = np.random.default_rng(3)
res = trend_test(rng.standard_normal(40), method="runs")
print(res.statistic, res.bounds, res.trend_free)

alternating = np.tile([1.0, -1.0], 10)   # 20 runs: rejected the other way
print(trend_test(alternating, method="runs").trend_free)   # False
```

The reverse arrangement test is the more powerful of the two against the
monotonic trends that dominate practice (B&P Sec. 4.5.2), which is why it
is the default everywhere; the runs test adds sensitivity to non-monotonic
clustering.

## 4. Level crossings and the apparent frequency

For a zero-mean Gaussian record with one-sided autospectrum $G(f)$, Rice's
classical results give the expected rate of zero crossings (both slopes,
B&P Eq. (5.195)) from the plain frequency moments
$m_k = \int f^k\,G(f)\,\mathrm{d}f$:

$$
N_0 = \frac{1}{\pi}\frac{\sigma_v}{\sigma_x} = 2\sqrt{\frac{m_2}{m_0}},
\qquad
N_a = N_0\, e^{-a^2/2\sigma_x^2},
$$

where $N_a$ is the crossing rate of level $a$ (Eq. (5.196)). $N_0/2$ is the
record's **apparent frequency**: a 60 Hz sine crosses zero 120 times per
second, low-pass noise of bandwidth $B$ gives $N_0 = 2B/\sqrt{3}$ (an
apparent frequency of $0.58B$, Example 5.12) and a band centred on $f_c$
gives $N_0 = 2\sqrt{f_c^2 + B^2/12}$ (Example 5.13).
`level_crossing_rate` counts the actual crossings of each level and puts
the Rice curve next to them, taking the moments from the record's own
[Welch autospectrum](https://jmrplens.github.io/phonometry/guides/spectral-analysis/):

```python
import numpy as np
from phonometry import level_crossing_rate

fs = 8192.0
t = np.arange(1 << 16) / fs
x = np.sin(2 * np.pi * 60.0 * t)          # a 60 Hz sine ...

res = level_crossing_rate(x, fs)
print(round(res.zero_crossing_rate, 1))   # ... has 120 zeros per second
print(round(res.apparent_frequency, 1))   # 60.0
res.plot()
```

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/rice_level_crossings_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/rice_level_crossings.svg" alt="Measured level-crossing rates of a bandlimited Gaussian noise record between eight hundred and twelve hundred hertz, plotted as dots against the crossing level from minus three point five to plus three point five signal units on a logarithmic rate axis, falling from about two thousand crossings per second at level zero to a few per second at three sigma, with the Rice exponential curve passing through every measured point" width="82%"></picture>

<details>
<summary>Show the code for this figure</summary>

```python
import matplotlib.pyplot as plt
import numpy as np
from phonometry import level_crossing_rate

fs = 20480.0
n = 1 << 19
rng = np.random.default_rng(0)
freqs = np.fft.rfftfreq(n, 1 / fs)        # bandlimited Gaussian noise:
spec = rng.standard_normal(freqs.size) + 1j * rng.standard_normal(freqs.size)
spec[(freqs < 800.0) | (freqs > 1200.0)] = 0.0
x = np.fft.irfft(spec, n)

res = level_crossing_rate(x, fs, levels=np.linspace(-3.5, 3.5, 29) * np.std(x))

fig, ax = plt.subplots(figsize=(10, 6))
ax.plot(res.levels, res.rice_rates, label="Rice (Eq. 5.196)")
ax.plot(res.levels, res.rates, "o", label="Measured")
ax.set_yscale("log")
ax.set_xlabel("Level a [signal units]")
ax.set_ylabel("Crossings per second [1/s]")
ax.legend()
plt.show()
```

</details>

The Rice curve holds for Gaussian records, and that is precisely its second
use: measured rates that fall systematically off the curve are a quick
non-Gaussianity screen (B&P Sec. 5.5.1.1) - clipping shows up as missing
high-level crossings, impulsive contamination as an excess. Count-based
rates need the record comfortably oversampled: a crossing between two
samples of the same sign goes uncounted.

## 5. Peaks: rates, the irregularity factor and heights

The same moments fix the expected rate of local maxima
$M = (1/2\pi)(\sigma_a/\sigma_v) = \sqrt{m_4/m_2}$ (Eq. (5.211)) and with
it the dimensionless **irregularity factor**

$$
r = \frac{N_0}{2M} = \frac{m_2}{\sqrt{m_0\, m_4}} \in (0, 1],
$$

the single number that fixes the distribution of peak heights
(B&P Sec. 5.5.4). At $r = 1$ - narrow bandwidth data, one maximum per
zero-crossing cycle - peaks are **Rayleigh** distributed:
$\mathrm{Prob}[\text{peak} > a] = e^{-a^2/2\sigma_x^2}$ (Eq. (5.206)), which
is the one-in-3000 chance of a peak beyond $4\sigma$ of B&P Example 5.14.
As $r \to 0$ ever more ripples ride on each cycle, negative maxima appear,
and the peak heights approach the plain **Gaussian** amplitude distribution.
In between, Rice's mixture (Eqs. (5.217)/(5.223)) interpolates the two,
available as `peak_exceedance()` / `peak_density()` on the result:

```python
import numpy as np
from phonometry import peak_statistics

fs = 8192.0
t = np.arange(1 << 16) / fs
x = np.sin(2 * np.pi * 60.0 * t)          # narrowband: r is essentially 1

res = peak_statistics(x, fs)
print(round(res.irregularity_factor, 3))       # 0.997
print(res.peak_exceedance(4.0))                # exp(-8): about 1 in 3000
```

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/rice_peak_distribution_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/rice_peak_distribution.svg" alt="Peak-height exceedance probability of low-pass Gaussian noise on a logarithmic axis against the standardized peak height from minus two point five to four point five: the empirical staircase from half a million samples follows the Rice mixture curve for irregularity factor zero point seven four six, clearly below the dashed Rayleigh limit and above the dotted Gaussian limit, with a visible fraction of negative maxima" width="82%"></picture>

<details>
<summary>Show the code for this figure</summary>

```python
import matplotlib.pyplot as plt
import numpy as np
from phonometry import peak_statistics
from phonometry.metrology.random_data import _rice_peak_exceedance

fs = 20480.0
n = 1 << 19
rng = np.random.default_rng(3)
freqs = np.fft.rfftfreq(n, 1 / fs)        # low-pass noise: r = sqrt(5)/3
spec = rng.standard_normal(freqs.size) + 1j * rng.standard_normal(freqs.size)
spec[freqs > 2000.0] = 0.0
x = np.fft.irfft(spec, n)

res = peak_statistics(x, fs)
peaks = res.peak_values
empirical = 1.0 - np.arange(1, peaks.size + 1) / peaks.size
z = np.linspace(-2.5, 4.5, 400)

fig, ax = plt.subplots(figsize=(10, 6))
ax.plot(z, _rice_peak_exceedance(z, 1.0), "--", label="Rayleigh (r = 1)")
ax.plot(z, _rice_peak_exceedance(z, 0.0), ":", label="Gaussian (r = 0)")
ax.plot(z, res.peak_exceedance(z),
        label=f"Rice (r = {res.irregularity_factor:.3f})")
ax.plot(peaks, empirical, drawstyle="steps-post", label="Empirical")
ax.set_yscale("log")
ax.set_ylim(1e-5, 1.5)
ax.set_xlabel(r"Standardized peak height $z = a/\sigma_x$")
ax.set_ylabel("Prob[peak > z]")
ax.legend()
plt.show()
```

</details>

For an ideal low-pass band the closed forms give $r = \sqrt{5}/3 = 0.745$,
and the measured value lands on it. The irregularity factor is the standard
bridge to fatigue and vibro-acoustic damage estimation, where it selects
the cycle-counting correction between the narrow-band Rayleigh assumption
and broad-band rainflow corrections. One practical warning: $m_4$ weights
the spectrum by $f^4$, so wideband instrumentation noise far above the
physical band silently inflates $M$ and deflates $r$ - band-limit the
record to the physically meaningful range first.

## Where this fits

Qualification comes *before* the statistics this section's other pages
compute: the chi-square confidence interval of a
[Welch PSD](https://jmrplens.github.io/phonometry/guides/spectral-analysis/) and the random-error
formulas of the [correlation estimators](https://jmrplens.github.io/phonometry/guides/correlation-delay/)
all assume the record is stationary, as does the very idea of *the* Leq of
a measurement in [Levels](https://jmrplens.github.io/phonometry/guides/levels/). When a record fails
the test, split it at the change (the `segment_values` sequence shows
where), analyse the pieces, or move to the short-time views - the
[calibrated spectrogram](https://jmrplens.github.io/phonometry/guides/time-frequency/) - that do not
assume stationarity. And when it passes, the
[GUM machinery](gum-uncertainty.md) can propagate the
*remaining* random error of every averaged estimate with a clean
conscience.

## References

- Bendat, J. S., & Piersol, A. G. (2010). *Random Data: Analysis and
  Measurement Procedures* (4th ed.). Wiley. ISBN 978-0-470-24877-5.
  [doi:10.1002/9781118032428](https://doi.org/10.1002/9781118032428).
  Section 4.5.2 with Table A.6 (the nonparametric reverse arrangement trend
  test, its mean and variance, the tabulated percentage points and
  Example 4.4), Section 10.3 (data qualification; the segment mean-square
  stationarity procedure of 10.3.1.1 and Example 10.3) and Section 5.5
  (level crossings and peak values, Examples 5.12-5.15). The runs companion
  appeared in the third edition's percentage points of the run distribution.
- Wald, A., & Wolfowitz, J. (1940). On a test whether two samples are from
  the same population. *The Annals of Mathematical Statistics*, 11(2),
  147-162. [doi:10.1214/aoms/1177731909](https://doi.org/10.1214/aoms/1177731909).
  The exact conditional distribution of the number of runs.
- Rice, S. O. (1945). Mathematical analysis of random noise. *The Bell
  System Technical Journal*, 24(1), 46-156.
  [doi:10.1002/j.1538-7305.1945.tb00453.x](https://doi.org/10.1002/j.1538-7305.1945.tb00453.x).
  The original level-crossing and peak derivations (Parts I-II are in
  volume 23, 1944).

---


<!-- source: docs/spectral-analysis.md | canonical: https://jmrplens.github.io/phonometry/guides/spectral-analysis/ -->

# Calibrated spectral analysis (Bendat & Piersol)

A spectrum without its uncertainty is half a measurement. This page covers the
Welch spectral estimators of `phonometry.metrology` that report, next to the
spectrum itself, the statistical quality of the estimate following Bendat &
Piersol, *Random Data: Analysis and Measurement Procedures* (4th ed., 2010):
the **power spectral density** and **cross-spectral density** with the
effective number of averages, the normalized random error and chi-square
confidence intervals; the **coherent output spectrum** that splits a measured
output into the part linearly explained by the input and the noise remainder,
with the spectral signal-to-noise ratio; a **fractional-octave smoother** with
a constant-power kernel; **colored-noise generators** with an exact
power-law slope for exercising all of the above; the **window figures of
merit** of Harris (1978) for choosing the taper; and a **Thomson multitaper
estimator** (Percival & Walden, 1993) for records too short to segment.
Every error formula is a closed form from the sources, verified by seeded
Monte Carlo in the test suite.

## 1. Power spectral density with its statistical error

`power_spectral_density` estimates the one-sided autospectral density
`Gxx(f)` by Welch's method: the record is split into tapered (Hann by
default), 50 %-overlapped segments whose periodograms are averaged. No
detrending is applied, so absolute calibration is preserved: a signal in
pascals yields `Pa²/Hz`. Two scalings are available: `'density'` (units²/Hz,
integrates to the signal power) and `'spectrum'` (units², reads the power of
discrete tones directly).

Averaging `nd` independent segments gives the estimate `2·nd` chi-square
degrees of freedom (Eq. 8.162), from which everything else follows:

$$
\varepsilon_r[\hat{G}_{xx}] = \frac{1}{\sqrt{n_d}}, \qquad
\frac{n\,\hat{G}_{xx}}{\chi^2_{n;\,\alpha/2}} \le G_{xx} \le
\frac{n\,\hat{G}_{xx}}{\chi^2_{n;\,1-\alpha/2}}, \quad n = 2 n_d .
$$

With overlapped, tapered segments the averages are correlated, so the result
reports both the raw segment count (`n_segments`) and the **effective**
number of independent averages (`n_averages`), computed with the
window-correlation formula of Welch (1967) that Bendat & Piersol reference in
Section 11.5.2.2; for a Hann taper at 50 % overlap it is roughly 0.95 of the raw
count. The random error and the confidence interval use the effective value.
At DC, and at Nyquist for an even segment length, the one-sided spectrum
has a single real Fourier component, so those bins carry half the degrees of
freedom and a correspondingly wider interval.

```python
from phonometry import power_spectral_density

res = power_spectral_density(signal, fs)          # Hann, 50 % overlap, 95 % CI
print(res.n_averages, res.random_error)           # nd and 1/sqrt(nd)
print(res.ci_lower[10], res.psd[10], res.ci_upper[10])
res.plot()                                        # PSD in dB with the CI band
```

The **resolution bias** is the other half of the error budget: a finite
analysis bandwidth `Be` (reported as `resolution_bandwidth`, the effective
noise bandwidth of the taper) smooths sharp spectral features, always in the
direction of reduced dynamic range (Eq. 8.139). For a resonance peak of
half-power bandwidth `Br`, the first-order normalized bias is the closed form
of Eq. 8.141, exposed as `resolution_bias_error`:

$$
\varepsilon_b[\hat{G}_{xx}(f_r)] \approx -\frac{1}{3}\left(\frac{B_e}{B_r}\right)^2 .
$$

```python
from phonometry import resolution_bias_error

eps_b = resolution_bias_error(res.resolution_bandwidth, 25.0)  # Br = 25 Hz peak
```

Narrow `Be` (long segments) suppresses the bias but leaves fewer averages and
a larger random error; the two requirements on segment length pull in
opposite directions, which is exactly the trade-off the reported numbers make
visible.

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/psd_confidence_smoothing_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/psd_confidence_smoothing.svg" alt="Welch power spectral density of pink noise in dB per Hz over 20 Hz to 20 kHz, with the 95 percent chi-square confidence band shaded around the estimate, the 1/3-octave smoothed curve on top and the exact -3.01 dB per octave power law as a dashed reference line" width="82%"></picture>

<details>
<summary>Show the code for this figure</summary>

```python
import matplotlib.pyplot as plt
import numpy as np
from phonometry import (
    fractional_octave_smoothing,
    noise_signal,
    power_spectral_density,
)

fs = 48000.0
x = noise_signal(fs, 20.0, color="pink", seed=11)
res = power_spectral_density(x, fs, nperseg=4096)
band = (res.frequencies >= 20.0) & (res.frequencies <= 20000.0)
freqs = res.frequencies[band]
smooth = fractional_octave_smoothing(res.frequencies, res.psd, 3.0)[band]

fig, ax = plt.subplots(figsize=(10, 6))
ax.fill_between(freqs, 10 * np.log10(res.ci_lower[band]),
                10 * np.log10(res.ci_upper[band]), alpha=0.3,
                label="95 % chi-square confidence interval")
ax.semilogx(freqs, 10 * np.log10(res.psd[band]), lw=1.0,
            label="Welch PSD estimate")
ax.semilogx(freqs, 10 * np.log10(smooth), lw=2.2,
            label="1/3-octave smoothed")
ax.set_xlabel("Frequency [Hz]")
ax.set_ylabel("PSD [dB re 1/Hz]")
ax.legend()
plt.show()
```

</details>

## 2. Cross-spectral density

`cross_spectral_density` estimates the complex `Gxy(f)` between two channels
with the same Welch core, and reports the ordinary coherence
`γ²xy = |Gxy|²/(Gxx·Gyy)` together with the Bendat & Piersol random errors of
the magnitude and phase (Eqs. 9.33 and 9.52, with the measured coherence in
place of the unknown true value, as the book recommends for measured data):

$$
\varepsilon_r[|\hat{G}_{xy}|] = \frac{1}{|\gamma_{xy}|\sqrt{n_d}}, \qquad
\mathrm{s.d.}[\hat{\theta}_{xy}] =
\frac{\left(1-\gamma^2_{xy}\right)^{1/2}}{|\gamma_{xy}|\sqrt{2 n_d}} .
$$

Both shrink as the coherence approaches one: a strongly coherent pair needs
far fewer averages for the same confidence. The phase is unwrapped, so its
slope against frequency is the group delay `τ_g = -dφ/(2π·df)`; for a pure
delay path the phase is linear and that slope reads the propagation delay
directly.

```python
from phonometry import cross_spectral_density

res = cross_spectral_density(x, y, fs)
print(res.magnitude_random_error[100], res.phase_std[100])  # bin 100 errors
res.plot()   # magnitude, phase with ±sigma band, coherence
```

## 3. Coherent output spectrum and spectral SNR

In the single-input/single-output model the measured output autospectrum
splits exactly into the part linearly explained by the input and the
uncorrelated remainder (Eqs. 9.55–9.57):

$$
G_{vv} = \gamma^2_{xy}\,G_{yy}, \qquad
G_{nn} = \left(1-\gamma^2_{xy}\right) G_{yy}, \qquad
\mathrm{SNR}(f) = \frac{\gamma^2_{xy}}{1-\gamma^2_{xy}} .
$$

`coherent_output_spectrum` returns all three spectra, the spectral
signal-to-noise ratio (linear and in dB) and the random error of the coherent
output estimate (Eq. 9.73), plus the first-order propagation of the coherence
error through the SNR:

$$
\varepsilon_r[\hat{G}_{vv}] =
\frac{\left(2-\gamma^2_{xy}\right)^{1/2}}{|\gamma_{xy}|\sqrt{n_d}}, \qquad
\varepsilon_r[\widehat{\mathrm{SNR}}] = \frac{\sqrt{2}}{|\gamma_{xy}|\sqrt{n_d}} .
$$

For additive uncorrelated output noise of known level the coherence has the
closed form `γ² = SNR/(1+SNR)`, which makes the whole chain verifiable with a
synthetic signal:

```python
import numpy as np
from phonometry import coherent_output_spectrum, noise_signal

fs = 48000.0
x = noise_signal(fs, 8.0, color="white", seed=1)
noise = noise_signal(fs, 8.0, color="white", rms=0.5, seed=2)
y = 0.8 * x + noise                      # SNR = 0.64/0.25 at every frequency

res = coherent_output_spectrum(x, y, fs)
print(np.median(res.coherence))          # -> SNR/(1+SNR) = 0.719
print(np.median(res.snr_db))             # -> 10·lg(2.56) = 4.1 dB
res.plot()                               # Gyy, Gvv, Gnn and the SNR panel
```

The `coherence_bias` field reports the small positive bias of the coherence
estimate, `b[γ̂²] ≈ (1-γ²)²/nd` (Eq. 9.75), negligible once `nd` reaches a
few hundred, and another reason to average generously before trusting a low
coherence.

## 4. Fractional-octave smoothing

`fractional_octave_smoothing` averages a spectrum over a rectangular window
of constant relative width: 1/n octave, `[f·2^(-1/2n), f·2^(+1/2n)]` around
each frequency. This is the constant-percentage resolution bandwidth that
Bendat & Piersol recommend for the spectra of resonant systems
(Section 8.5.3), and the de facto standard for presenting loudspeaker and
room responses. The average is always computed on **power** (amplitudes are
squared first, dB levels converted and back), so band power is conserved
rather than amplitude, and a flat spectrum passes through exactly unchanged.

```python
from phonometry import fractional_octave_smoothing

smooth_psd = fractional_octave_smoothing(res.frequencies, res.psd, 3.0)
smooth_mag = fractional_octave_smoothing(freqs, np.abs(response), 6.0,
                                         domain="amplitude")  # an FRF |H|
smooth_db = fractional_octave_smoothing(freqs, levels, 3.0, domain="db")  # dB curve
```

A single spectral line with PSD ordinate `P` (units²/Hz) in a bin of width
`Δf` smooths to the closed-form level `P·Δf / (f₀·(2^{1/2n} - 2^{-1/2n}))`
over one kernel width, the oracle pinned in the tests.

## 5. Colored-noise generators

`noise_signal` produces Gaussian noise whose PSD follows `Gxx(f) ∝ f^α`
exactly in expectation: seeded white noise is shaped in the frequency domain
by the exact magnitude response `(f/f_ref)^{α/2}` bin by bin (a zero-phase
filter applied circularly), so a measured slope deviates from the power law
only by the random error of the spectral estimate, not by the piecewise or
few-pole pink approximations whose slope ripples by fractions of a dB. The
record is zero-mean and rescaled to the requested RMS exactly, and the same
seed reproduces the same record bit for bit.

| color | α | PSD slope |
|---|---|---|
| `white` | 0 | 0 dB/octave |
| `pink` | -1 | -3.01 dB/octave |
| `red` (Brownian) | -2 | -6.02 dB/octave |
| `blue` | +1 | +3.01 dB/octave |
| `violet` | +2 | +6.02 dB/octave |

```python
from phonometry import noise_signal

pink = noise_signal(48000, 10.0, color="pink", seed=7)     # deterministic
white = noise_signal(48000, 10.0, color="white", rms=0.5, seed=7)
```

Measured over three decades (20 Hz – 20 kHz) with the estimator of section 1,
the regression slope of each color lands within a few thousandths of a
dB/octave of the exact value; the conformance suite pins the pink slope at
-3.0116 against the exact -3.0103.

## 6. Choosing the window

Every estimator on this page accepts any window `scipy.signal.get_window`
knows, but the choice is a quantified trade-off, not a preference.
`window_metrics` computes the figures of merit Harris (1978) tabulated, for
any taper and length, sampled DFT-even exactly as the Welch estimators apply
it:

- **ENBW** (equivalent noise bandwidth, in bins): how much wider than one
  bin the effective analysis bandwidth is. This is the same number the PSD
  result reports as `resolution_bandwidth` (`ENBW·fs/nperseg` in Hz), and
  it enters directly in the tone/noise trade: a broadband noise floor read
  from a windowed spectrum sits `10·lg(ENBW)` dB above the true density.
- **Coherent gain**: the DC gain `Σw/N` a bin-centered tone is scaled by.
- **Scalloping loss**: the worst-case attenuation of a tone that falls
  midway between two bins (3.92 dB for rectangular, 1.42 dB for Hann).
- **Worst-case processing loss**: scalloping plus `10·lg(ENBW)`, the
  worst-case reduction in output SNR for tone detection in white noise.
- **Highest sidelobe** and **main-lobe -3 dB width**: leakage floor versus
  resolution.

```python
from phonometry import window_metrics

m = window_metrics("hann", 2048)
print(m.enbw_bins)              # 1.5, exactly
print(m.scalloping_loss_db)     # 1.42 dB
print(m.highest_sidelobe_db)    # -31.5 dB
m.plot()                        # window + spectrum with metrics marked
```

The closed forms anchor the tests: ENBW is exactly 1 for rectangular, 3/2
for Hann, 1987/1458 for Hamming and 1523/882 for Blackman (DFT-even
sampling), and the rectangular scalloping loss is `20·lg(N·sin(π/2N))`,
the Dirichlet kernel evaluated half a bin off center.

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/window_functions_tradeoff_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/window_functions_tradeoff.svg" alt="Spectra of the rectangular, Hann, Hamming and Blackman windows over 16 DFT bins, showing the trade-off between main-lobe width and sidelobe level, with each window's equivalent noise bandwidth and highest sidelobe level in the legend" width="82%"></picture>

The Hann default of this module is the balanced choice: fast sidelobe
falloff (-18 dB/octave) protects noise spectra from leakage, ENBW 1.5 costs
only 1.76 dB against the rectangular window, and its overlap correlation at
50 % keeps nearly all segment information in the effective average count.
Reach for a lower-sidelobe taper (Blackman, Kaiser with a high beta) when a
weak tone must be found next to a strong one, and accept the wider main
lobe; reach for the rectangular window only for self-windowing records
(transients that decay inside the segment) or bin-centered synthesis.

## 7. Multitaper estimation for short records

Welch's method buys stability with record length: each independent segment
adds two degrees of freedom, so a record that only fits a couple of segments
leaves an estimate barely better than a periodogram. `multitaper_psd`
implements the alternative of Thomson (1982), as developed by Percival &
Walden (1993, Chapter 7): the *whole* record is multiplied by `K` orthogonal
discrete prolate spheroidal (Slepian) tapers - the sequences that concentrate
the most spectral-window energy inside a chosen design band `[-W, W]` - and
the `K` resulting *eigenspectra* are averaged:

$$
\hat{S}^{(mt)}(f) = \frac{1}{K}\sum_{k=0}^{K-1} \hat{S}_k(f), \qquad
\hat{S}_k(f) = \Delta t\,\Bigl|\sum_{t=1}^{N} h_{t,k}\,x_t\,
e^{-i 2\pi f t \Delta t}\Bigr|^2 .
$$

Because the tapers are orthogonal the eigenspectra are nearly uncorrelated,
so the average carries about `2K` chi-square degrees of freedom from a
single record - the same statistical machinery as the Welch result (random
error, chi-square confidence interval), without segmenting. The
half-bandwidth `W = NW·fs/N` is set through the duration x half-bandwidth
product `NW` (default 4). `2W` is the resolution of the estimate (reported
as `resolution_bandwidth`), and only the tapers below the Shannon number
`2·NW` keep their spectral-window energy inside the design band - their
concentrations `λk` are reported as `eigenvalues`, and the default taper
count is `K = 2·NW - 1`, all tapers with near-unity concentration. Larger
`NW` admits more tapers (lower variance) at the cost of resolution.

```python
from phonometry import multitaper_psd

res = multitaper_psd(signal, fs)                 # NW = 4, K = 7, adaptive
print(res.degrees_of_freedom.mean())             # ~2K from one record
print(res.eigenvalues)                           # taper concentrations
res.plot()                                       # density with the CI band
```

By default the eigenspectra are combined with Thomson's **adaptive weights**
(P&W Eqs. 368a/370a, iterated to convergence). Each taper's weight at each
frequency balances the local spectrum against the broad-band leakage the
taper could carry:

$$
b_k(f) = \frac{S(f)}{\lambda_k S(f) + (1-\lambda_k)\,\sigma^2 \Delta t},
\qquad
\hat{S}^{(amt)}(f) =
\frac{\sum_k b_k^2(f)\,\lambda_k\,\hat{S}_k(f)}
     {\sum_k b_k^2(f)\,\lambda_k},
$$

so the leakier high-order tapers are downweighted exactly where the spectrum
is locally weak, and nothing is lost where it is locally white (for white
noise the weights are uniform). The price is bookkept honestly: the
equivalent degrees of freedom become frequency dependent,
`ν(f) = 2·(Σk dk)²/Σk dk²` with `dk = b²k·λk` (P&W Eq. 370b), and the
confidence interval widens wherever leakage protection spent them.
`adaptive=False` selects the plain eigenvalue-weighted average instead.

Calibration matches the Welch estimators exactly: no detrending,
`'density'` integrates to the signal power, and `'spectrum'` reads `A²/2` at
the peak of a sinusoid of amplitude `A` (a tone's power in `'density'`
scaling spreads over the `2W` band). The Slepian tapers themselves come from
`scipy.signal.windows.dpss`; their concentrations reproduce the
quadruple-precision table of Percival & Walden (Table 382) to machine
precision, which is the anchor oracle of the test suite.

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/multitaper_psd_confidence_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/multitaper_psd_confidence.svg" alt="Thomson multitaper spectral density of a 171 millisecond pink noise record in dB per Hz over 20 Hz to 20 kHz, with the 95 percent chi-square confidence band around the seven-taper adaptive estimate, the single-taper estimate as a jagged gray context line and the exact -3.01 dB per octave power law as a dashed reference" width="82%"></picture>

<details>
<summary>Show the code for this figure</summary>

```python
import matplotlib.pyplot as plt
import numpy as np
from phonometry import multitaper_psd, noise_signal

fs = 48000.0
x = noise_signal(fs, 8192 / fs, color="pink", seed=11)   # 171 ms record
single = multitaper_psd(x, fs, n_tapers=1, adaptive=False)
res = multitaper_psd(x, fs)                              # NW = 4, K = 7
band = (res.frequencies >= 20.0) & (res.frequencies <= 20000.0)
freqs = res.frequencies[band]

fig, ax = plt.subplots(figsize=(10, 6))
ax.semilogx(freqs, 10 * np.log10(single.psd[band]), color="gray",
            alpha=0.45, lw=0.7, label="Single Slepian taper (K = 1)")
ax.fill_between(freqs, 10 * np.log10(res.ci_lower[band]),
                10 * np.log10(res.ci_upper[band]), alpha=0.3,
                label="95 % chi-square confidence interval")
ax.semilogx(freqs, 10 * np.log10(res.psd[band]), lw=1.2,
            label="Multitaper estimate (K = 7, adaptive)")
ax.set_xlabel("Frequency [Hz]")
ax.set_ylabel("PSD [dB re 1/Hz]")
ax.legend()
plt.show()
```

</details>

Reach for `multitaper_psd` when the record is too short to segment (room
impulse response tails, transient captures, single machine cycles) or when
a high-dynamic-range spectrum needs leakage protection that a Hann-windowed
Welch average cannot give; stay with `power_spectral_density` for long
records, where segment averaging is cheaper than `K` full-length FFTs and
the two estimators agree.

## Relation to the H1/H2 estimators

The [frequency-response estimators](electroacoustics.md) `transfer_function`
and `coherence`, the two-microphone [sound intensity](https://jmrplens.github.io/phonometry/guides/intensity/) probe and
these estimators all share one Welch core (same taper, overlap policy and
detrend-off calibration), so a PSD, a coherence and an H1 computed with the
same segment length are mutually consistent bin by bin. The same cross-spectral
matrix underlies [multiple and partial coherence](https://jmrplens.github.io/phonometry/guides/miso-coherence/), which
extends the ordinary coherence to several correlated inputs and one
output.

## References

- Bendat, J. S., & Piersol, A. G. (2010). *Random Data: Analysis and
  Measurement Procedures* (4th ed.). Wiley. ISBN 978-0-470-24877-5.
  [doi:10.1002/9781118032428](https://doi.org/10.1002/9781118032428).
  Sections 5.2 and 8.5 (autospectra and their random/bias errors,
  chi-square intervals), 9.1–9.2 (cross-spectra, coherent output spectrum
  and their errors) and 11.5 (Welch processing, tapering and overlap).
- Welch, P. D. (1967). The use of fast Fourier transform for the estimation
  of power spectra: A method based on time averaging over short, modified
  periodograms. *IEEE Transactions on Audio and Electroacoustics*, 15(2),
  70–73. [doi:10.1109/TAU.1967.1161901](https://doi.org/10.1109/TAU.1967.1161901).
  The overlapped-segment variance formula behind the effective number of
  averages (Bendat & Piersol Section 11.5.2.2, Ref. 11).
- Harris, F. J. (1978). On the use of windows for harmonic analysis with the
  discrete Fourier transform. *Proceedings of the IEEE*, 66(1), 51–83.
  [doi:10.1109/PROC.1978.10837](https://doi.org/10.1109/PROC.1978.10837).
  The window figures of merit (Table 1) computed by `window_metrics`.
- Thomson, D. J. (1982). Spectrum estimation and harmonic analysis.
  *Proceedings of the IEEE*, 70(9), 1055–1096.
  [doi:10.1109/PROC.1982.12433](https://doi.org/10.1109/PROC.1982.12433).
  The multitaper method: Slepian tapers, eigenspectra and the adaptive
  weights implemented by `multitaper_psd`.
- Percival, D. B., & Walden, A. T. (1993). *Spectral Analysis for Physical
  Applications: Multitaper and Conventional Univariate Techniques*.
  Cambridge University Press. ISBN 978-0-521-43541-3.
  [doi:10.1017/CBO9780511622762](https://doi.org/10.1017/CBO9780511622762).
  Chapter 7 (multitaper estimation: eigenspectra, adaptive weighting,
  equivalent degrees of freedom) and Chapter 8 (the Slepian sequences);
  the Table 382 eigenvalues anchor the taper oracle in the test suite.

---


<!-- source: docs/miso-coherence.md | canonical: https://jmrplens.github.io/phonometry/guides/miso-coherence/ -->

# Multiple and partial coherence (Bendat & Piersol)

When several partially correlated sources drive one response, the ordinary
coherence of each source with the output is misleading: a source that only
*correlates* with the true cause inherits a spurious coherence through it, so
reading the ordinary coherences alone can credit the wrong source. Bendat &
Piersol, *Random Data* (4th ed., 2010, Chapter 7), resolve this for a
multiple-input/single-output (MISO) system with the **multiple** and
**partial** coherence functions. `miso_coherence` computes them from the same
Welch cross-spectral core as the rest of `phonometry.metrology`, for several
correlated inputs and one output.

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/miso_coherence_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/miso_coherence.svg" alt="Two-panel figure. Top: the measured output autospectrum in dB with the coherent output contribution of two inputs shaded underneath; input 1 fills the low band and input 2 the high band, with the residual noise far below. Bottom: for the correlated second input, its ordinary coherence sits around 0.3 across the low band even though it drives no low-frequency path, while its partial coherence collapses to zero there once the first input is conditioned out; the multiple coherence stays near one except at the crossover null" width="82%"></picture>

<details>
<summary>Show the code for this figure</summary>

```python
import matplotlib.pyplot as plt
import numpy as np
from scipy import signal
from phonometry import miso_coherence, noise_signal

fs = 8192.0
# Input 1 drives a low-frequency path; input 2 = 0.7*x1 + independent noise
# drives a high-frequency path, so input 2 is correlated with input 1.
x1 = noise_signal(fs, 32.0, color="white", seed=1)
x2 = 0.7 * x1 + noise_signal(fs, 32.0, color="white", seed=2)
low = signal.butter(4, 400.0, fs=fs, output="sos")
high = signal.butter(4, 1500.0, btype="high", fs=fs, output="sos")
noise = noise_signal(fs, 32.0, color="white", rms=0.05, seed=3)
y = signal.sosfilt(low, x1) + signal.sosfilt(high, x2) + noise

res = miso_coherence([x1, x2], y, fs, nperseg=2048)
f = res.frequencies
band = (f >= 20.0) & (f <= 4000.0)

fig, (ax_top, ax_bot) = plt.subplots(2, 1, figsize=(10, 7.4), sharex=True)
db = lambda v: 10 * np.log10(v)
ax_top.semilogx(f[band], db(res.output_psd[band]), color="gray",
                label="Measured output")
for i, color in ((0, "#1f77b4"), (1, "#2ca02c")):
    ax_top.semilogx(f[band], db(res.coherent_output_spectra[i][band]),
                    color=color, label=f"Input {i + 1} contribution")
ax_top.semilogx(f[band], db(res.noise_psd[band]), "--", color="#d62728",
                label="Residual noise")
ax_top.set_ylabel("Coherent output [dB re 1/Hz]")
ax_top.legend()

ax_bot.semilogx(f[band], res.ordinary_coherence[1][band], ":", color="#2ca02c",
                label="Input 2 ordinary (inflated by x1)")
ax_bot.semilogx(f[band], res.partial_coherence[1][band], color="#2ca02c",
                label="Input 2 partial (x1 removed)")
ax_bot.semilogx(f[band], res.multiple_coherence[band], color="black",
                label="Multiple")
ax_bot.set_xlabel("Frequency [Hz]")
ax_bot.set_ylabel("Coherence")
ax_bot.set_ylim(0, 1.05)
ax_bot.legend()
plt.show()
```

</details>

The `.plot()` method draws the same two panels in one call (`res.plot()`, or
`res.plot(language="es")` for Spanish labels).

## 1. Ordinary, multiple and partial coherence

For a system with `q` inputs `x1..xq` and output `y`, `miso_coherence`
estimates every auto- and cross-spectrum by Welch's method and reports three
coherence functions.

The **ordinary coherence** of input `i` with the output, on its own
(Eq. 7.109), is the familiar single-input quantity
`γ²iy = |Giy|²/(Gii·Gyy)`.

The **multiple coherence** (Eq. 7.35) is the fraction of the output
autospectrum linearly explained by *all* inputs jointly,
`γ²y:x = GxyᴴGxx⁻¹Gxy/Gyy = 1 - Gnn/Gyy`, where `Gnn` is the residual output
spectrum uncorrelated with every input. If the output carries additive
uncorrelated noise of per-band signal-to-noise ratio `SNR`, this is exactly
`SNR/(1+SNR)`, the closed-form oracle used to verify the implementation.

The **partial coherence** of input `i` (Eq. 7.87),
`γ²iy·(i-1)! = |Giy·(i-1)!|²/(Gii·(i-1)!·Gyy)`, is its coherence with the
output once the linear effect of the inputs *before it in the conditioning
order* has been removed. The 4th-edition definition keeps the *total* output
`Gyy` in the denominator, so the partial coherences of the ordered inputs sum
to the multiple coherence (Eq. 7.116) and reduce exactly to the ordinary
coherences when the inputs are mutually uncorrelated (Eq. 7.117).

```python
res = miso_coherence([x1, x2], y, fs)
res.ordinary_coherence   # shape (q, F): each input on its own
res.multiple_coherence   # shape (F,):  all inputs jointly
res.partial_coherence    # shape (q, F): conditioned on the preceding inputs
```

## 2. Conditioning: separating cause from correlation

The partial coherences come from the **conditioned spectra** `Gij·r!`,
computed by the Gaussian-elimination recursion of Section 7.3. Removing the
linear effect of a pivot input `r` from every remaining record is one Schur
complement step (Eq. 7.94),
`Gij·r! = Gij·(r-1)! - Grj·(r-1)!·Gir·(r-1)!/Grr·(r-1)!`, applied for each
input in the conditioning order until only the residual output spectrum
`Gyy·q!` remains. In the figure above, input 2 is `0.7·x1 + independent
noise`, so it correlates with input 1 but drives no low-frequency path. Its
ordinary coherence with the output therefore reads about 0.3 across the low
band, borrowed entirely through input 1, while its partial coherence collapses
to zero there once input 1 is conditioned out.

```python
low = (res.frequencies > 100.0) & (res.frequencies < 300.0)
res.ordinary_coherence[1][low].mean()   # ~0.32  (inflated through x1)
res.partial_coherence[1][low].mean()    # ~0.00  (x1 removed)
```

## 3. Which source dominates each band

The conditioning also splits the output power source by source. The **partial
coherent output spectrum** of input `i` (Eq. 7.86), `Gvi = γ²iy·(i-1)!·Gyy`,
is the share of the output autospectrum it contributes, and the shares plus
the residual noise reconstruct the output exactly (Eqs. 7.88/7.121):
`sum(Gvi) + Gnn = Gyy`. Comparing the shares band by band answers "which
source dominates here?". `dominant_input()` returns, for every frequency, the
index of the input with the largest share.

```python
res.coherent_output_spectra        # shape (q, F): Gvi per input
dominant = res.dominant_input()    # index of the strongest source per bin
f = res.frequencies
dominant[np.argmin(abs(f - 200.0))]    # 0 (input 1 drives the low band)
dominant[np.argmin(abs(f - 2500.0))]   # 1 (input 2 drives the high band)
```

## 4. Statistical quality and the conditioning order

The random errors follow Section 9.3. Conditioning on the `i-1` preceding
inputs costs `i-1` degrees of freedom, so the `i`-th ordered input carries
`nd-(i-1)` effective averages (Eqs. 9.100/9.101) and the `q`-input multiple
coherence carries `nd-(q-1)` (Eq. 9.98). The result exposes
`multiple_coherence_random_error` and `coherent_output_random_error` alongside
the effective average count `n_averages`.

The ordinary and multiple coherences do not depend on the conditioning order,
but the partial coherences and the coherent-output decomposition do. Absent a
physical ordering, Bendat & Piersol (Section 7.2.4) recommend ordering the
inputs by descending ordinary coherence with the output; pass `order` to
choose (for example `order=(2, 0, 1)`). The estimators share the Welch core of
the [calibrated spectral analysis](https://jmrplens.github.io/phonometry/guides/spectral-analysis/) page, so a MISO
coherence and a `power_spectral_density` computed with the same segment length
are consistent bin by bin.

## References

- Bendat, J. S., & Piersol, A. G. (2010). *Random Data: Analysis and
  Measurement Procedures* (4th ed.). Wiley. ISBN 978-0-470-24877-5.
  [doi:10.1002/9781118032428](https://doi.org/10.1002/9781118032428).
  Chapter 7 (multiple-input/output relationships: ordinary coherence
  Eq. 7.109, multiple coherence Eq. 7.35, partial coherence Eq. 7.87, the
  conditioned-spectrum recursion Eq. 7.94, the coherent output decomposition
  Eqs. 7.86/7.116/7.121) and Section 9.3 (statistical errors of the multiple
  and conditioned estimates, Eqs. 9.98-9.101).

---


<!-- source: docs/correlation-delay.md | canonical: https://jmrplens.github.io/phonometry/guides/correlation-delay/ -->

# Correlation, time delay and envelope (Bendat & Piersol / Knapp & Carter)

Where the [calibrated spectral estimators](https://jmrplens.github.io/phonometry/guides/spectral-analysis/) describe a
signal in frequency, this page covers their time-domain counterparts in
`phonometry.metrology`: **auto- and cross-correlation** estimates with the
three standard normalizations and their Bendat & Piersol random errors;
**time-delay estimation** (TDE) by the direct correlator, the cross-spectrum
phase slope and the **generalized cross-correlation** (GCC) of Knapp & Carter
with the Roth, SCOT, PHAT and maximum-likelihood weightings; **sub-sample
peak location** for impulse-response delays and alignment; and the **Hilbert
envelope** with instantaneous phase and frequency. The GCC estimators run on
the same Welch core as the spectral densities, so both views of a signal pair
are mutually consistent bin by bin.

## 1. Correlation estimates

`correlation` computes the auto- or cross-correlation via zero-padded FFT so
the circular product never wraps (Bendat & Piersol Section 11.4.2), with the
sign convention of the book's time-delay model: for
`y(t) = α·x(t-τ0) + n(t)` the estimate peaks at `τ = +τ0` (Eq. 5.21). Three
normalizations are available:

- `'biased'`: the lag sums divided by `N`; tapers toward the record ends
  and stays bounded by `[Rxx(0)·Ryy(0)]^1/2`;
- `'unbiased'`: divided by `N-|r|` (Eq. 11.96), an unbiased estimate of
  `Rxy(τ)` whose variance grows toward the ends;
- `'coefficient'`: the correlation coefficient function
  `ρxy(τ) = Cxy(τ)/(σx·σy)` in [-1, 1] over the mean-removed records
  (Eq. 5.16).

```python
import numpy as np
from phonometry import correlation

res = correlation(x, y, fs, normalization="coefficient", max_lag=0.05)
peak = np.argmax(res.values)
print(res.lags[peak], res.values[peak])   # delay and its coefficient
res.plot()
```

The result always carries the coefficient function alongside the requested
normalization, because the coefficient is what the error formulas need. For
bandwidth-limited Gaussian data of bandwidth `B` observed for `T` seconds
(Eqs. 8.109/8.112, valid for `T ≥ 10·|τ|` and `BT ≥ 5`):

$$
\varepsilon\!\left[\hat{R}_{xy}(\tau)\right] =
\frac{\left[1 + \rho^{-2}_{xy}(\tau)\right]^{1/2}}{\sqrt{2BT}},
\qquad
\varepsilon\!\left[\hat{R}_{xx}(0)\right] = \frac{1}{\sqrt{BT}} .
$$

`res.random_error(signal_bandwidth)` evaluates it per lag with the measured
coefficient, and the standalone `correlation_random_error` takes an explicit
coefficient; with `ρ = S/√((S+M)(S+N)) = 1/11`, `B = 100` Hz and `T = 5` s
it reproduces the `ε ≈ 0.35` of the book's Example 8.5, one of the pinned
conformance anchors. Two closed forms anchor the estimator itself in the
tests: the autocorrelation of a sine, `(A²/2)·cos(2πf0τ)`, and the
`sin(2πBτ)/(2πBτ)` autocorrelation of bandwidth-limited white noise
(Eq. 8.120).

## 2. Time-delay estimation

`time_delay` estimates the delay of `y` relative to `x` in the two-sensor
model `y(t) = α·x(t-τ0) + n(t)` (B&P Section 5.1.4) by three routes:

- **`'direct'`**: the peak of the full-record correlation coefficient
  function;
- **`'phase'`**: the `|Gxy|`-weighted least-squares slope of the
  cross-spectrum phase (Eq. 5.101b): a pure delay has an exactly linear
  phase, so this estimator resolves fractional delays to better than 1e-3
  samples without any peak interpolation, as long as the unwrapped phase is
  unambiguous (clean, moderate delays);
- **`'gcc'`**: the generalized cross-correlation of Knapp & Carter (1976):
  the Welch-averaged cross-spectrum is weighted by `ψ(f)` before the inverse
  transform, sharpening the peak that the signal's own autocorrelation would
  otherwise smear (their Eq. 9).

The weightings of Knapp & Carter's Table I, with the conditions the paper
attaches to each:

| `weighting` | `ψ(f)` | Behaviour and conditions |
|---|---|---|
| `'none'` | 1 | The plain correlator: the delta at the delay is convolved with the signal autocorrelation, giving a broad peak on colored signals. |
| `'roth'` | `1/Gxx` | Suppresses the bands where the *first* sensor is noisy; still smears unless that noise is spectrally similar to the signal. |
| `'scot'` | `1/√(Gxx·Gyy)` | Prewhitens both channels symmetrically; equals Roth when the sensors match. |
| `'phat'` | `1/|Gxy|` | Ideally a delta at the delay for uncorrelated noises (their Eq. 23), but the weight ignores the signal-to-noise ratio, so bands without signal contribute unit-magnitude random phase. It needs signal power across the analysis band. |
| `'ml'` | `γ²/(|Gxy|·(1-γ²))` | The Hannan-Thomson maximum-likelihood processor: a PHAT weighted down by the phase variance each band actually carries. Attains the Cramér-Rao bound; the safe default when the signal does not fill the band. |

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/gcc_phat_delay_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/gcc_phat_delay.svg" alt="Normalized cross-correlation of a colored two-sensor signal pair against lag in milliseconds: the direct correlator shows a broad peak around the true 20-sample delay while the GCC-PHAT curve collapses to a sharp spike exactly on the dashed true-delay line" width="82%"></picture>

<details>
<summary>Show the code for this figure</summary>

```python
import matplotlib.pyplot as plt
import numpy as np
from scipy import signal as sp_signal
from phonometry import noise_signal, time_delay

fs = 8192.0
delay = 20  # samples
b, a = sp_signal.butter(2, 800.0 / (fs / 2.0))   # colored common signal
s = sp_signal.lfilter(b, a, noise_signal(fs, 4.0, color="white", seed=10))
x = s + noise_signal(fs, 4.0, color="white", rms=0.02, seed=11)
y = np.roll(s, delay) + noise_signal(fs, 4.0, color="white", rms=0.02, seed=12)

direct = time_delay(x, y, fs, method="direct", max_delay=0.01)
phat = time_delay(x, y, fs, method="gcc", weighting="phat",
                  nperseg=2048, max_delay=0.01)

fig, ax = plt.subplots(figsize=(10, 6))
ax.plot(1e3 * direct.lags,
        direct.correlation / np.max(np.abs(direct.correlation)),
        label="Direct cross-correlation")
ax.plot(1e3 * phat.lags, phat.correlation, label="GCC-PHAT")
ax.axvline(1e3 * delay / fs, ls="--", color="k", label="True delay")
ax.set_xlabel("Lag [ms]")
ax.set_ylabel("Normalized correlation")
ax.legend()
plt.show()
```

</details>

The correlation-peak methods refine the sample peak by three-point parabolic
interpolation, optionally after **band-limited local upsampling**
(`upsample=16` resamples a window around the peak sixteenfold before the
parabola). Sub-sample accuracy presumes the peak is oversampled, i.e. the
signals are band-limited below Nyquist; on a 0.4·fs band-limited pair the
tests pin the achievable error at ≲0.1 sample for the parabola alone and
≲2e-3 samples with `upsample=16`. For GCC the delay must fit within half a
Welch segment; raise `nperseg` for longer delays.

With `signal_bandwidth` given, the result also carries the **peak-location
uncertainty** of B&P Eq. 8.129 and its ±2σ interval (Eq. 8.130):

$$
\sigma(\hat{\tau}_0) \approx
\left(\tfrac{3}{4}\right)^{1/4}
\frac{\sqrt{\varepsilon[\hat{R}_{xy}(\tau_0)]}}{\pi B} .
$$

```python
from phonometry import time_delay

res = time_delay(x, y, fs, method="gcc", weighting="ml",
                 nperseg=2048, upsample=16, signal_bandwidth=1000.0)
print(res.delay, res.delay_samples)     # seconds and fractional samples
print(res.delay_std, res.delay_interval)  # Eq. 8.129 sigma, +/-2 sigma
res.plot()                              # correlation with the delay marked
```

The formula models the peak of the continuous correlation function, so treat
the interval as a conservative order-of-magnitude bound; the seeded Monte
Carlo in the test suite observes the actual scatter *below* the prediction.

## 3. Impulse-response delay and alignment

The cross-correlation of an impulse response with an ideal unit impulse is
the IR itself, so the sub-sample location of its peak magnitude is its
arrival time. `impulse_response_delay` applies exactly the same refinement
as the TDE peak (local band-limited upsampling, default ×8, plus the
parabola), and with a `reference` IR it measures the delay between the pair
from their full-record cross-correlation (one-shot transients are not
stationary records, so the direct correlator is used rather than the
Welch-averaged GCC):

```python
from phonometry import align_impulse_responses, impulse_response_delay

t_arrival = impulse_response_delay(ir, fs)              # seconds from t = 0
dt = impulse_response_delay(ir_b, fs, reference=ir_a)   # pair delay

res = align_impulse_responses(ir_b, ir_a, fs)  # remove the estimated delay
res.plot()                                     # reference vs aligned overlay
```

`align_impulse_responses` removes the estimated delay with an exact
band-limited fractional shift (a frequency-domain phase ramp over a
zero-padded record, so nothing wraps around): the tool for averaging IR
ensembles or comparing measurements taken at slightly different distances.
The synthetic fractional-delay tests document the achievable accuracy on a
smooth band-limited pulse: about 1e-2 samples with the parabola alone,
1e-3 at the default `upsample=8`, below 1e-5 at ×32.

## 4. Hilbert envelope and instantaneous frequency

`envelope` builds the analytic signal `z(t) = x(t) + j·x̃(t)` by the
one-sided spectrum construction that Bendat & Piersol recommend
(Eq. 13.25) and returns the three Chapter 13 quantities on one time axis:

$$
A(t) = \left[x^2(t) + \tilde{x}^2(t)\right]^{1/2}, \qquad
\theta(t) = \arctan\frac{\tilde{x}(t)}{x(t)}, \qquad
f(t) = \frac{1}{2\pi}\frac{d\theta}{dt} .
$$

For an amplitude-modulated carrier `u(t)·cos(2πf0t)` the envelope recovers
`u(t)` exactly (Eq. 13.27); the conformance suite pins the recovered AM
envelope and the Table 13.1 pair `cos → sin` at the 1e-9 level, and the
instantaneous frequency of a chirp tracks its sweep.

```python
from phonometry import envelope

res = envelope(x, fs)
print(res.envelope, res.instantaneous_frequency)
res.plot()               # signal + envelope, instantaneous frequency

slow = envelope(x, fs, decimation_factor=32)   # anti-aliased, fs/32
```

The envelope of a band-limited signal is itself low-frequency, so the result
offers optional **decimation**: a zero-phase FIR anti-alias filter by
default, or plain subsampling with `antialias=False`, exactly the
convention the ECMA-418-2 loudness/roughness chain of
`phonometry.psychoacoustics` applies internally after its auditory bandpass
(Formulae 65/119 of the standard), appropriate when the input is already
narrowband.

## Relation to the spectral estimators

`time_delay` (GCC and phase methods) runs on the same Welch core (taper,
overlap policy, detrend-off calibration, segment defaults) as
[`cross_spectral_density`](https://jmrplens.github.io/phonometry/guides/spectral-analysis/) and the H1/H2
[frequency-response estimators](electroacoustics.md), so a GCC, a coherence
and a cross-spectrum computed with the same segment length agree bin by bin;
the `'phase'` estimator is literally the slope of the
`CrossSpectralDensityResult` phase, weighted as Eq. 5.101b prescribes.

The Hilbert envelope here is the time-domain companion of the
[envelope spectrum](https://jmrplens.github.io/phonometry/guides/cepstrum-echoes/), which reads the same modulations off
as discrete lines in frequency; and the sub-sample alignment shares its
band-limited kernel with the public
[fractional-delay and resampling tools](https://jmrplens.github.io/phonometry/guides/test-signals/).

## References

- Bendat, J. S., & Piersol, A. G. (2010). *Random Data: Analysis and
  Measurement Procedures* (4th ed.). Wiley. ISBN 978-0-470-24877-5.
  [doi:10.1002/9781118032428](https://doi.org/10.1002/9781118032428).
  Sections 5.1.4 and 5.2.6-5.2.7 (time delay via correlation and
  cross-spectrum), 8.4 (random errors of correlation estimates and of the
  peak location), 11.4 (FFT computation with zero padding) and Chapter 13
  (Hilbert transforms, envelope and instantaneous phase).
- Knapp, C. H., & Carter, G. C. (1976). The generalized correlation method
  for estimation of time delay. *IEEE Transactions on Acoustics, Speech,
  and Signal Processing*, 24(4), 320-327.
  [doi:10.1109/TASSP.1976.1162830](https://doi.org/10.1109/TASSP.1976.1162830).
  The GCC framework, the Table I weightings and their conditions, and the
  maximum-likelihood (Hannan-Thomson) processor.

---


<!-- source: docs/test-signals.md | canonical: https://jmrplens.github.io/phonometry/guides/test-signals/ -->

# Test signals and sample-rate tools (IEC 60268-1)

A measurement is only as trustworthy as its stimulus and its sample-rate
bookkeeping. This page covers the signal toolbox of `phonometry.metrology`:
**tone bursts** with the exact gating IEC 60268-1 prescribes, the
**colored-noise generators** (detailed in the
[spectral analysis guide](https://jmrplens.github.io/phonometry/guides/spectral-analysis/#5-colored-noise-generators)),
**resampling** whose anti-alias rejection is a stated, verifiable
specification rather than a library default, and **fractional delay** that
shifts a record by any sub-sample amount with band-limited exactness.

## 1. Tone bursts (IEC 60268-1)

The gated sine burst is the standard stimulus for dynamic behaviour:
sound-level-meter ballistics, quasi-peak meters, loudspeaker power handling.
IEC 60268-1:1985 (Clause A2.1) pins down what a well-formed burst is: it
"should start at the zero-crossing of the tone and should consist of an
integral number of full periods". `tone_burst` generates exactly that, as a
single burst or as the repetitive train of Clause A2.2 in which each burst
occupies one full repetition period:

```python
from phonometry import tone_burst

# One 5 ms burst of 5 kHz tone (25 full periods), as in Table AII.
single = tone_burst(48000, 5000, 25)
print(single.burst_samples)         # 240 samples = 5 ms at 48 kHz

# Clause A2.2: 5 ms bursts at 10 bursts per second.
train = tone_burst(48000, 5000, 25, repetitions=4, repetition_rate=10)
print(train.period_samples, train.duty_cycle)   # 4800, 0.05
train.plot()                        # waveform with the gating envelope
```

The result carries the record, the rectangular gating **envelope** and the
exact sample bookkeeping (`burst_samples`, `onset_sample`, `period_samples`,
`duty_cycle`), so a test report can state its stimulus numerically. Because
the gate spans an integral number of full periods starting at a zero
crossing, the burst energy has the closed form `A²N/2` exactly, which is how
the generator is verified.

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/tone_burst_train_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/tone_burst_train.svg" alt="IEC 60268-1 tone bursts: a single 5 ms burst of 5 kHz tone starting at a zero crossing with its rectangular gating envelope, and a repetitive train of four bursts at 10 bursts per second with a 5 percent duty cycle" width="82%"></picture>

These are the bursts behind the
[Fast/Slow/Impulse ballistics](https://jmrplens.github.io/phonometry/guides/time-weighting/) reference responses
(IEC 61672-1 Table 4 uses 4 kHz tonebursts of 200, 50 and 10 ms) and the
quasi-peak dynamic tests of IEC 60268-1 itself (5 kHz bursts of 1 to 200 ms,
Table AII).

## 2. Resampling with a stated anti-alias specification

Sample-rate conversion hides a filter, and that filter decides how much
aliased energy contaminates the result. `resample_signal` performs rational
polyphase resampling (44.1 to 48 kHz is the ratio 160/147) with a lowpass
FIR designed *inside the function* by the Kaiser window method, from two
numbers the caller controls:

- `stopband_attenuation_db` (default 120): the alias rejection, with the
  stopband starting exactly at the smaller of the two Nyquist frequencies,
  where folding happens;
- `transition_width` (default 0.05): the fraction of that Nyquist frequency
  given up to the filter's transition band, so the passband ends at
  `(1 - transition_width)·f_Nyq` and is flat within the same Kaiser ripple
  bound `δ = 10^(-A/20)`.

```python
from phonometry import noise_signal, resample_signal

x = noise_signal(44100, 5.0, color="pink", seed=1)
res = resample_signal(x, 44100, 48000)   # 120 dB alias rejection
print(res.up, res.down)                  # 160, 147
print(res.n_taps, res.passband_edge_hz)  # designed FIR, 20947.5 Hz
```

The designed taps travel with the result (`filter_taps`), so the
specification is *checkable*: the test suite measures the frequency response
of the returned filter and asserts the passband deviation and stopband
leakage against the design's own ripple bound (the design internally targets
1 dB past the request, so the delivered filter meets the stated numbers
rather than a Kaiser-formula approximation of them). A passband tone
resampled through the default specification matches the analytic tone at the
new rate within `10^(-120/20) = 10^-6`.

Several estimators resample internally at fixed rates (the ECMA-418-2
psychoacoustics at 48 kHz, STOI at 10 kHz); this function is the public,
documented counterpart for preparing records outside those chains.

## 3. Fractional delay

`fractional_delay` shifts a record by any number of samples, including
sub-sample amounts, by multiplying the spectrum with the phase ramp
`e^(-j2πf·D/fs)`: every component is delayed by exactly `D` samples. Two
boundary conventions cover the two use cases:

- `mode="linear"` (default) zero-pads the record past the shift, so samples
  leaving one end land in padding instead of wrapping around. Use it for
  transients and impulse responses; it is bit-identical to the alignment
  kernel inside [`align_impulse_responses`](https://jmrplens.github.io/phonometry/guides/correlation-delay/). An
  integer delay reduces to an exact sample shift.
- `mode="circular"` applies the ramp over the record itself and wraps. For
  periodic records it is exact: a tone centered on a DFT bin delayed by
  `D` samples equals the analytically delayed tone to machine precision,
  and its phase changes by exactly `-2πf·D/fs` radians.

```python
import numpy as np
from phonometry import fractional_delay

y = fractional_delay(x, 0.37)                    # 0.37 samples later
z = fractional_delay(x, -2.5, mode="circular")   # advance, wrapped
```

One subtlety worth knowing: a real record of even length cannot carry a
fractionally delayed Nyquist-bin component (the inverse real FFT keeps only
its real part). Any properly sampled signal is band-limited below Nyquist,
so in practice the operation is exact; for synthetic corner cases, odd
lengths avoid the bin entirely.

## 4. Colored noise

The deterministic colored-noise generators (`noise_signal`: white, pink,
red, blue, violet with an exact power-law slope and bit-reproducible seeds)
complete the toolbox; they are documented with their spectral verification
in the [spectral analysis guide](https://jmrplens.github.io/phonometry/guides/spectral-analysis/#5-colored-noise-generators).

## Where these tools are used

The [window figures of merit](https://jmrplens.github.io/phonometry/guides/spectral-analysis/#6-choosing-the-window)
quantify the taper every spectral estimate in the library rests on; the
burst generator feeds ballistics and dynamic-response testing; and the
resampler and fractional delay are the sample-rate half of
[correlation and delay work](https://jmrplens.github.io/phonometry/guides/correlation-delay/), where sub-sample
alignment is the difference between averaging impulse responses and
smearing them.

## References

- International Electrotechnical Commission. (1985). *Sound system
  equipment — Part 1: General* (IEC 60268-1:1985).
  Annex A, Clause A2: tone bursts starting at the zero crossing of the tone
  with an integral number of full periods (A2.1), repetitive burst trains
  at a stated repetition rate (A2.2), and the Table AII burst durations the
  sample counts are hand-checked against.
- Bendat, J. S., & Piersol, A. G. (2010). *Random Data: Analysis and
  Measurement Procedures* (4th ed.). Wiley. ISBN 978-0-470-24877-5.
  [doi:10.1002/9781118032428](https://doi.org/10.1002/9781118032428).
  Section 10.2 (data preparation: sampling, aliasing and the anti-alias
  filtering requirement the resampler states explicitly).

---


<!-- source: docs/cepstrum-echoes.md | canonical: https://jmrplens.github.io/phonometry/guides/cepstrum-echoes/ -->

# Cepstrum, echoes and the envelope spectrum (Havelock / Bendat & Piersol)

The [spectral estimators](https://jmrplens.github.io/phonometry/guides/spectral-analysis/) describe *what
frequencies* a signal contains; this page covers what hides in the *shape* of
that spectrum. The **cepstrum** - the inverse Fourier transform of the log
spectrum - lives in `phonometry.metrology` and turns two hard spectral
problems into easy peak-picking: periodic spectral ripple (an echo, a harmonic
family) collapses onto a single spike at the **quefrency** of its period, and
the smooth spectral envelope separates from the fine structure by plain
windowing - **liftering** - in the quefrency domain. The same machinery
extends the [Hilbert envelope](https://jmrplens.github.io/phonometry/guides/correlation-delay/) with an
**envelope spectrum** in which amplitude modulations become discrete lines.

## 1. The cepstrum and its three variants

Because the log turns the convolution `x = h * u` into the sum
`ln X = ln H + ln U`, components that overlap in the spectrum add - and
separate - in the cepstral domain (Havelock Ch. 27, Eqs. (22)-(23)). `cepstrum`
computes the three standard variants over the quefrency axis:

- `'power'` (default): the inverse DFT of `ln|X|²` (Milner's Fig. 21). Real,
  even and phase-blind - the workhorse for echo and harmonic-family
  detection;
- `'real'`: the inverse DFT of `ln|X|` - exactly half the power cepstrum,
  and the quantity whose causal folding is the minimum-phase reconstruction
  (below);
- `'complex'`: the inverse DFT of `ln|X| + j·arg X` with the phase unwrapped
  and its linear component removed (Havelock Ch. 87, Eq. (14)). It keeps the
  phase, so it is **invertible**: the entry point to homomorphic
  deconvolution.

```python
import numpy as np
from phonometry import cepstrum

fs = 48000.0
rng = np.random.default_rng(1)
x = rng.standard_normal(4096)

res = cepstrum(x, fs, kind="power")
print(res.quefrencies[:3], res.cepstrum.shape)   # quefrency axis, in s
res.plot()
```

The result carries the full periodic quefrency axis (`0 .. (nfft-1)/fs`);
quefrencies above `nfft/(2·fs)` are the mirrored negative quefrencies, where
the even power and real cepstra repeat and the complex cepstrum keeps its
anticausal (non-minimum-phase) content. Zero-padding via `nfft` reduces
cepstral time-aliasing when the log spectrum has sharp features, exactly like
the `oversample` padding of
[`minimum_phase`](https://jmrplens.github.io/phonometry/guides/swept-sine-distortion/).

## 2. Echo detection: the rahmonic spike train

A single reflection `x(t) = s(t) + a·s(t-t0)` multiplies the spectrum by
`1 + a·e^{-j2πft0}` - a ripple of period `1/t0` across the whole band. Its
logarithm expands, for `|a| < 1`, into the exactly summable series

$$
\ln\!\left(1 + a e^{-j\theta}\right)
= \sum_{n \ge 1} (-1)^{n+1} \frac{a^n}{n}\, e^{-jn\theta},
$$

so the cepstrum carries a spike train at the **rahmonics** `n·t0` with
amplitudes `a, -a²/2, a³/3, ...` (their sum is `ln(1+a)`), regardless of the
spectrum of `s` itself, which concentrates at low quefrencies. On the power
cepstrum the first spike's height is exactly the reflection coefficient `a` -
a closed form the tests and the conformance suite pin to 1e-10.
`echo_detection` automates the reading:

```python
import numpy as np
from phonometry import echo_detection

fs = 48000.0
rng = np.random.default_rng(2)
s = rng.standard_normal(12000)               # broadband source
x = s + 0.5 * np.roll(s, 384)                # echo: 8 ms, a = 0.5

res = echo_detection(x, fs, min_quefrency=0.002)
print(res.delay, res.reflection_coefficient)  # 0.008 s, ~0.5
res.plot()
```

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/cepstrum_echo_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/cepstrum_echo.svg" alt="Power cepstrum of an impulse response with one reflection against quefrency in milliseconds: a sharp positive spike at exactly 8 milliseconds marked as the detected echo, a smaller negative second rahmonic at 16 milliseconds, the low-quefrency source envelope outside the shaded searched band, and a dashed line at the true delay" width="82%"></picture>

<details>
<summary>Show the code for this figure</summary>

```python
import matplotlib.pyplot as plt
import numpy as np
from scipy import signal as sp_signal
from phonometry import echo_detection, noise_signal

fs = 48000.0
n = 12000
impulse = np.zeros(n)
impulse[0] = 1.0
b, a = sp_signal.butter(2, [0.004, 0.9], btype="bandpass")
direct = sp_signal.lfilter(b, a, impulse)              # broadband click
ir = direct + 0.5 * np.roll(direct, int(0.008 * fs))   # echo at 8 ms
ir += noise_signal(fs, n / fs, color="white", rms=1e-4, seed=13)

res = echo_detection(ir, fs, min_quefrency=0.002)

fig, ax = plt.subplots(figsize=(10, 6))
half = res.nfft // 2 + 1
ax.plot(1e3 * res.quefrencies[:half], res.cepstrum[:half], lw=1.1)
ax.axvline(8.0, ls="--", color="k", label="True echo delay")
ax.plot([1e3 * res.delay], [res.reflection_coefficient], "v", ms=10,
        label="Detected peak (height = reflection a)")
ax.set_xlim(0.0, 30.0)
ax.set_xlabel("Quefrency [ms]")
ax.set_ylabel("Cepstrum")
ax.legend()
plt.show()
```

</details>

The searched band starts above the low-quefrency region occupied by the
source's own spectral envelope (`min_quefrency`, default 16 samples) and ends
at the unambiguous half of the axis (`max_quefrency`). The seismic
reverberation spike trains of Havelock Ch. 87 are the same signature at
geophysical scale. Note the negative second rahmonic at `2·t0` in the figure:
the `-a²/2` term of the series, a useful confirmation that a peak really is
an echo and not an unrelated spectral periodicity.

## 3. Liftering: envelope versus fine structure

Filtering in the quefrency domain is called **liftering** (Havelock Ch. 27,
Sec. 4.3). A *lowpass* lifter keeps the quefrencies below the cutoff and
returns the smooth log-spectral envelope with the ripple removed; a
*highpass* lifter keeps the complement - the ripple alone. The two modes are
exactly complementary in dB, because the split is linear in the log domain:

```python
import numpy as np
from phonometry import lifter

fs = 48000.0
rng = np.random.default_rng(3)
s = rng.standard_normal(12000)
x = s + 0.5 * np.roll(s, 384)                # the same 8 ms echo

low = lifter(x, fs, cutoff=0.004, mode="lowpass")    # envelope of ln|X|
high = lifter(x, fs, cutoff=0.004, mode="highpass")  # the echo ripple
print(np.allclose(low.liftered_db + high.liftered_db, low.spectrum_db))
low.plot()
```

For the pure-echo signal the highpass ripple swings between the closed forms
`20·log10(1+a)` and `20·log10(1-a)` dB, another oracle the tests pin. In
speech analysis the identical operation separates the vocal-tract envelope
(formants) from the excitation harmonics; here it is the general tool for
"smooth versus periodic" splits of any measured magnitude response.

## 4. The complex cepstrum and the minimum-phase connection

The complex cepstrum keeps the unwrapped phase, so the transform is a round
trip: `CepstrumResult.invert()` restores the record to machine precision,
including the linear-phase (pure delay) component that the forward transform
removes and stores in `linear_phase_samples`:

```python
import numpy as np
from scipy import signal as sp_signal
from phonometry import cepstrum

fs = 48000.0
x = np.zeros(2048)
b, a = sp_signal.butter(2, 0.3)
x[37:293] = sp_signal.lfilter(b, a, np.r_[1.0, np.zeros(255)])

res = cepstrum(x, fs, kind="complex")
print(res.linear_phase_samples)              # negative: a bulk delay removed
print(np.max(np.abs(res.invert() - x)))      # ~1e-14
```

Between the log and the inverse transform anything can be edited - that is
homomorphic deconvolution (Havelock Ch. 87, Sec. 3.3): zero the rahmonics to
remove an echo, keep only the low quefrencies to extract a source wavelet.
A minimum-phase signal has a causal complex cepstrum, which is why folding
the real cepstrum onto positive quefrencies reconstructs the minimum phase
from `|H|` alone: [`minimum_phase`](https://jmrplens.github.io/phonometry/guides/swept-sine-distortion/)
and `phase_decomposition` run on that same folding core (Bendat & Piersol
Sec. 13.1.4; Tohyama in Havelock Ch. 75 edits reverberation by manipulating
exactly these causal/anticausal parts).

## 5. The envelope spectrum: modulations as lines

Where the cepstrum finds periodicities *of the spectrum*, the **envelope
spectrum** finds periodicities *of the amplitude*. Bendat & Piersol
Section 13.3 (Fig. 13.11) formalizes the structure: an envelope detector, a
DC remover, and a spectral view of what remains. `envelope_spectrum` runs the
[Hilbert envelope](https://jmrplens.github.io/phonometry/guides/correlation-delay/) (`kind="magnitude"`,
the practical default) or the book's square-law detector (`kind="squared"`)
through exactly that chain, scaled so a sinusoidal modulation reads out as a
line at its exact amplitude.

For an AM tone `A0·(1 + m·cos(2πfm·t))·cos(2πfc·t)` the closed forms are:

| `kind` | mean level | line at `fm` | line at `2fm` |
|---|---|---|---|
| `'magnitude'` | `A0` | `A0·m` | - |
| `'squared'` | `A0²·(1 + m²/2)` | `2·A0²·m` | `A0²·m²/2` |

```python
import numpy as np
from phonometry import envelope_spectrum

fs = 8192.0
t = np.arange(int(4 * fs)) / fs
x = (1.0 + 0.4 * np.cos(2 * np.pi * 25.0 * t)) * np.cos(2 * np.pi * 1000.0 * t)

res = envelope_spectrum(x, fs)
k = int(round(25.0 * res.nfft / fs))
print(res.mean_level, res.amplitude[k])      # ~1.0 and ~0.4
res.plot()
```

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/envelope_spectrum_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/envelope_spectrum.svg" alt="Envelope spectrum of an amplitude-modulated one kilohertz tone in noise against frequency up to one hundred hertz: a single sharp line at the twenty-five hertz modulation frequency reaching exactly the dotted reference at amplitude zero point four, with a flat noise floor elsewhere" width="82%"></picture>

<details>
<summary>Show the code for this figure</summary>

```python
import matplotlib.pyplot as plt
import numpy as np
from phonometry import envelope_spectrum, noise_signal

fs = 8192.0
seconds = 4.0
t = np.arange(int(seconds * fs)) / fs
x = (1.0 + 0.4 * np.cos(2 * np.pi * 25.0 * t)) * np.cos(2 * np.pi * 1000.0 * t)
x += noise_signal(fs, seconds, color="white", rms=0.03, seed=8)

res = envelope_spectrum(x, fs)

fig, ax = plt.subplots(figsize=(10, 6))
ax.plot(res.frequencies, res.amplitude, lw=1.4, label="Envelope spectrum")
ax.axvline(25.0, ls="--", color="k", label="Modulation frequency")
ax.axhline(0.4, ls=":", color="r", label=r"Exact line amplitude $A_0 m$")
ax.set_xlim(0.0, 100.0)
ax.set_xlabel("Frequency [Hz]")
ax.set_ylabel("Modulation amplitude")
ax.legend()
plt.show()
```

</details>

Bearing and gear defects, mains hum and wind-turbine amplitude modulation all
appear this way: lines at the modulation frequency and its harmonics, cleanly
separated from the carrier's own spectrum. The envelope mean removed by the
DC step is kept in `mean_level` (the carrier amplitude for the magnitude
detector), and `remove_dc=False` skips the remover when the absolute DC line
matters.

## Relation to the other estimators

The cepstrum starts from the same one-record FFT conventions as the
[calibrated spectral estimators](https://jmrplens.github.io/phonometry/guides/spectral-analysis/), and its
folding core is literally the one inside
[`minimum_phase`](https://jmrplens.github.io/phonometry/guides/swept-sine-distortion/) - the refactor is
pinned bit-exact in the tests. The envelope spectrum is the frequency-domain
view of the same analytic signal the
[Hilbert envelope](https://jmrplens.github.io/phonometry/guides/correlation-delay/) returns in time, and a
natural pre-analysis before the dedicated
[wind-turbine amplitude-modulation](wind-turbine-noise.md)
metrics: the envelope spectrum tells you *whether and at what rate* a signal
is modulated, the domain metrics quantify it normatively.

## References

- Havelock, D., Kuwano, S., & Vorländer, M. (Eds.) (2008). *Handbook of
  Signal Processing in Acoustics*. Springer. ISBN 978-0-387-77698-9.
  [doi:10.1007/978-0-387-30441-0](https://doi.org/10.1007/978-0-387-30441-0).
  Chapter 27 (Milner: the cepstral transform as the inverse DFT of the log
  power spectrum, quefrency, lowpass/highpass liftering), Chapter 87
  (Neelamani: the complex cepstrum, homomorphic deconvolution, periodic
  spike trains from reverberation) and Chapter 75 (Tohyama: minimum-phase /
  all-pass manipulation in the cepstral domain).
- Bendat, J. S., & Piersol, A. G. (2010). *Random Data: Analysis and
  Measurement Procedures* (4th ed.). Wiley. ISBN 978-0-470-24877-5.
  [doi:10.1002/9781118032428](https://doi.org/10.1002/9781118032428).
  Section 13.1.4 (the Hilbert relation between log magnitude and phase
  behind the minimum-phase folding) and Section 13.3 with Figure 13.11
  (envelope detection followed by DC removal, the structure of the
  envelope spectrum).

---


<!-- source: docs/synchronous-averaging.md | canonical: https://jmrplens.github.io/phonometry/guides/synchronous-averaging/ -->

# Time synchronous averaging (McFadden 1987)

A rotating machine repeats its signature once per revolution. Buried in
broadband noise and in the tones of every other shaft, that repetitive
waveform is hard to read directly. **Time synchronous averaging** (TSA)
recovers it: given the period `T` of one revolution, it slices the record
into successive length-`T` blocks and averages them. Every component
synchronous with `T` reinforces; everything asynchronous, noise and the
harmonics of unrelated shafts, averages down. `time_synchronous_average`
implements the model of P. D. McFadden, *A revised model for the extraction
of periodic waveforms by time domain averaging* (Mechanical Systems and
Signal Processing 1(1), 1987, 83-95).

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/synchronous_average_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/synchronous_average.svg" alt="Two-panel figure. Left: one noisy period of a signal in faint grey swings far above and below a smooth curve; the average of forty periods, in blue, lies almost exactly on the dashed red true waveform, so the asynchronous noise has been removed. Right: the comb-filter magnitude across the orders between 31 and 33, with unit-height teeth at the integer orders; for N = 20 a deep node falls exactly on the interfering tone marked at 32.05 orders, whereas the power-of-two N = 32 leaves a side lobe there and lets the tone through" width="92%"></picture>

<details>
<summary>Show the code for this figure</summary>

```python
import matplotlib.pyplot as plt
import numpy as np
from phonometry import (
    comb_filter_response,
    noise_signal,
    time_synchronous_average,
)

fs = 8192.0
period = 1.0 / 32.0        # one revolution: 256 samples at this rate
m, n_avg = 256, 40
phase = np.arange((n_avg + 1) * m) / m
periodic = (
    np.cos(2.0 * np.pi * phase)
    + 0.5 * np.cos(2.0 * np.pi * 3.0 * phase + 0.4)
    - 0.3 * np.cos(2.0 * np.pi * 6.0 * phase)
)
signal = periodic + noise_signal(fs, phase.size / fs, rms=0.9, seed=11)
res = time_synchronous_average(signal, fs, period, n_averages=n_avg)

fig, (ax0, ax1) = plt.subplots(1, 2, figsize=(11, 4.6))
t_ms = 1e3 * res.times
ax0.plot(t_ms, signal[:m], color="#cccccc", label="One noisy period")
ax0.plot(t_ms, res.period_waveform, color="#1f77b4", lw=1.8,
         label=f"Average of N = {n_avg} periods")
ax0.plot(t_ms, periodic[:m], "--", color="#d62728", label="True waveform")
ax0.set_xlabel("Time [ms]"); ax0.set_ylabel("Amplitude"); ax0.legend()

orders = np.linspace(31.0, 33.0, 4000)
freqs = orders / period
ax1.plot(orders, comb_filter_response(freqs, period, 32), color="#2ca02c",
         label="N = 32 (power of two)")
ax1.plot(orders, comb_filter_response(freqs, period, 20), color="#1f77b4",
         label="N = 20 (node on 32.05)")
ax1.axvline(32.05, color="#d62728", ls=":", label="Interfering tone")
ax1.set_xlabel("Frequency [orders]"); ax1.set_ylabel("Comb filter magnitude")
ax1.set_ylim(0, 1.05); ax1.legend()
plt.show()
```

</details>

The `.plot()` method draws the averaged waveform and the comb filter in one
call:

```python
res.plot()          # English labels; res.plot(language="es") for Spanish
```

## 1. The average is a comb filter

Averaging `N` successive periods (McFadden Eq. 5),
`a(t) = (1/N) Σ y(t + n·T)`, is, in the frequency domain, the multiplication
of the signal spectrum by a **comb filter** (Eq. 8). Its magnitude (Eq. 9) is
the Dirichlet kernel `|C(f)| = |sin(N·π·f·T) / (N·sin(π·f·T))|`.

The comb has a **tooth** of unit height at every harmonic `k/T` (the orders
`f·T = 1, 2, 3, ...`), *independent of `N`*: components synchronous with the
period pass untouched. Between the teeth it has **nodes** at `j/(N·T)` for
every `j` that is not a multiple of `N`, where the response is exactly zero.
`comb_filter_response` evaluates this closed form directly:

```python
import numpy as np
from phonometry import comb_filter_response

period = 1.0 / 32.0
comb_filter_response(np.array([16.0 / period]), period, 8)   # 1.0 at a tooth
comb_filter_response(np.array([0.25 / period]), period, 2)   # 1/sqrt(2)
comb_filter_response(np.array([0.5 / period]), period, 2)    # 0.0 at a node
```

`SynchronousAverageResult` carries the response over the first few harmonics
in `comb_frequencies` and `comb_response`, so the shape of the filter that
the average applied is available alongside the recovered waveform.

## 2. Noise falls as the square root of the number of averages

Asynchronous noise of variance `σ²` averaged over `N` periods has residual
variance `σ²/N`: the residual standard deviation falls as `1/√N`, and the
amplitude signal-to-noise ratio improves by `√N`. That is a power reduction
of `10·log₁₀ N` dB, reported as `noise_reduction_db`, with the amplitude gain
`√N` as `amplitude_snr_gain`:

```python
res = time_synchronous_average(signal, fs, period, n_averages=100)
res.noise_reduction_db      # 20.0 dB = 10*log10(100)
res.amplitude_snr_gain      # 10.0   = sqrt(100)
```

This law is the ideal one: it holds when the noise is uncorrelated from one
period to the next, so colored or synchronous noise that is correlated across
periods need not follow it. The `residual` (input minus the periodic
reconstruction over the analysed span) and its `residual_rms` therefore report
the noise actually left once the synchronous component is removed.

## 3. Choosing N to reject an interfering order

Because a tooth sits on *every* integer order, TSA passes the harmonics of
the target shaft but also any tone that happens to fall on an integer order.
A tone at a *non-harmonic* order `q = f·T` is only attenuated by the comb,
not removed, and how much depends on where the nearest node lands. McFadden's
revised-model result is that such an interferer is best rejected by choosing
`N` so that a node falls exactly on it, i.e. the smallest `N` with `N·q` an
integer, rather than by the habitual power-of-two number of averages. An exact
node exists only when the order `q` is rational, so some finite `N` makes `N·q`
an integer; for an irrational or merely estimated order, choose the `N` whose
node falls nearest the interfering order.

His own example is a tone at 32.05 orders. With `N = 20` the product
`20 · 32.05 = 641` is an integer, so a comb node lands on the tone and rejects
it by more than 100 dB. The common choice `N = 32` gives `32 · 32.05 = 1025.6`,
which sits on a side lobe: the tone is barely touched. The figure above shows
both combs around order 32; the end-to-end average confirms it:

```python
# true 8th-order component plus a strong interferer at 32.05 orders
phase = np.arange(41 * 256) / 256
signal = np.cos(2 * np.pi * 8.0 * phase) + 0.7 * np.cos(2 * np.pi * 32.05 * phase)

leak_20 = time_synchronous_average(signal, fs, period, n_averages=20)
leak_32 = time_synchronous_average(signal, fs, period, n_averages=32)
# leak_20.period_waveform matches the clean 8th-order tone; leak_32 does not
```

So a power-of-two number of averages, convenient as it is, is not in general
the optimal choice: the interfering orders present in the machine should set
`N`.

## 4. Non-integer samples per period

When `fs·T` is an integer, the period boundaries fall on samples, the blocks
are sliced directly, and a noiseless periodic signal is recovered to machine
precision (`interpolated` is `False`). When `fs·T` is not an integer the
boundaries fall between samples; each block is then aligned to a common
integer grid by the band-limited fractional delay of
[`fractional_delay`](https://jmrplens.github.io/phonometry/guides/test-signals/), and the waveform is recovered within
that interpolation error (`interpolated` is `True`):

```python
fs = 8192.0
period = 1.0 / 31.7                       # fs * period is not an integer
t = np.arange(int(40 * period * fs)) / fs
signal = np.cos(2.0 * np.pi * t / period)  # one cycle per revolution
res = time_synchronous_average(signal, fs, period)
res.interpolated                          # True: fractional-delay alignment
res.samples_per_period                    # integer samples of one period
```

By default the average uses as many whole periods as the record holds; pass
`n_averages` to fix the count (for the node-selection choice of §3), and
`n_harmonics` to set how many harmonics of `1/T` the returned comb response
spans.

The band-limited alignment shares its kernel with the sub-sample
impulse-response alignment of the [test-signals page](https://jmrplens.github.io/phonometry/guides/test-signals/), and
the recovered waveform, being exactly one period, can be tiled to reconstruct
the synchronous part of the signal for subtraction or for order analysis.

---


<!-- source: docs/swept-sine-distortion.md | canonical: https://jmrplens.github.io/phonometry/guides/swept-sine-distortion/ -->

# Swept-sine distortion and phase utilities (Farina / Novak)

A single exponential sine sweep characterises the linear response and
every harmonic distortion order of a weakly nonlinear system at once.
After deconvolution, the distortion products of order `n` pack
into separate impulse responses that *precede* the linear response by the
fixed advance (Farina 2000)

$$
\Delta t_n = T\,\frac{\ln n}{\ln (f_2/f_1)} = L \ln n ,
\qquad L = \frac{T}{\ln(f_2/f_1)} ,
$$

so windowing each arrival yields the **higher harmonic frequency responses**
`H1(f), H2(f), ..., HN(f)` and, from them, the total harmonic distortion as
a function of the excitation frequency with one sweep instead of a
tone-by-tone stepping. This page covers that separation in
`phonometry.electroacoustics`, with the phase-coherent **synchronized
sweep** of Novak, Lotton & Simon (2015) as the default, and the companion
**phase utilities** in `phonometry.metrology`: minimum phase from `|H|`,
group delay and excess phase.

<picture>
  <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/swept_sine_thd_dark.svg">
  <img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/swept_sine_thd.svg" alt="Total harmonic distortion and the second and third harmonic ratios of a cubic polynomial followed by a 3 kHz low-pass, against excitation frequency: each order sits exactly on its Chebyshev level at low frequency and rolls off where its own product crosses the filter corner" width="82%">
</picture>

## 1. One sweep, every harmonic

For an exponential sweep the instantaneous frequency rises as
`f(t) = f1·e^(t/L)`, so the moment the excitation passes `f`, the n-th
harmonic distortion product appears at `n·f`: exactly where the sweep
itself will be `L·ln(n)` seconds later. Deconvolving the recording against
the sweep therefore time-compresses each order into its own impulse
response, `L·ln(n)` *before* the linear one. `swept_sine_distortion`
windows each arrival (with the exact fractional-sample alignment), Fourier
transforms it into `Hn(f)`, and reads the distortion of order `n` at
excitation frequency `f` from `|Hn(n·f)|`:

$$
\mathrm{THD}(f) =
\frac{\sqrt{\sum_{n\ge 2} |H_n(nf)|^2}}{|H_1(f)|} .
$$

```python
import numpy as np
from phonometry import swept_sine_distortion, synchronized_sweep_signal

fs, f1, f2, seconds = 48000, 20.0, 6000.0, 4.0
x = synchronized_sweep_signal(fs, f1, f2, seconds)   # play this...
# ... record the device response into `y` (include the decay tail) ...
res = swept_sine_distortion(y, fs, f1, f2, seconds, n_harmonics=3)

res.harmonic_responses    # complex H1..H3 on res.frequencies
res.thd, res.thd_frequencies
res.distortion_ratios     # |Hn(n f)| / |H1(f)| per order
res.plot()                # |Hn| magnitudes + THD(f)
```

The oracle behind the implementation is the memoryless polynomial: driving
`y = x + a2·x² + a3·x³` with a unit sweep must return, by the Chebyshev
identities, `|H1| = 1 + 3a3/4`, `|H2| = a2/2` (phase `-π/2`), `|H3| = a3/4`
(phase `π`) and `THD = √((a2/2)² + (a3/4)²)/(1 + 3a3/4)`. The test and
conformance suites pin all four, and the same THD measured tone by tone
with `phonometry.thd` agrees to 0.1 %.

## 2. The synchronized sweep (Novak et al. 2015)

Windowing separates the harmonic *magnitudes* with any exponential sweep,
but the *phases* of `H2..HN` are only meaningful if delaying the sweep by
`L·ln(n)` is exactly equivalent to generating its n-th harmonic. That holds
only for

$$
x(t) = \sin\!\big[2\pi f_1 L\, e^{t/L}\big],
\qquad
L = \frac{1}{f_1}\,\mathrm{round}\!\Big(\frac{f_1\,\tilde T}{\ln(f_2/f_1)}\Big),
$$

the **synchronized swept-sine**: the rounding makes `f1·L` an integer, so
the sweep starts at zero phase and every harmonic copy lines up.
`synchronized_sweep_signal` generates it (the duration is quantized
slightly; when `f2/f1` is an integer the sweep also ends at zero phase),
and `swept_sine_distortion(..., method="synchronized")`, the default,
deconvolves with the closed-form spectrum of the inverse filter,

$$
\tilde X(f) = 2\sqrt{f/L}\;
e^{-j 2\pi f L\,(1 - \ln(f/f_1)) + j\pi/4},
$$

rather than an FFT of the signal. Besides being exact, the analytic
deconvolution extends the usable band of each `Hn` to `[n·f1, n·f2]`
(Novak et al., Fig. 6): the second harmonic of a 6 kHz sweep is measured
up to 12 kHz.

Two practical notes from the paper are built in: the recording mean is
subtracted by default (`remove_dc=True`; a DC offset otherwise leaks a
scaled inverse filter into the impulse response), and the non-integer part
of each arrival `L·ln(n)·fs` is removed in the frequency domain, so the
harmonic phases carry no residual sub-sample skew.

## 3. Analysing classical ESS recordings (`method="farina"`)

Recordings made with the plain exponential sweep of
`phonometry.sweep_signal` (the ISO 18233 excitation used by
[`impulse_response`](https://jmrplens.github.io/phonometry/guides/room-acoustics/)) are analysed with
`method="farina"`: the same windowing over the time-reversed,
amplitude-compensated inverse filter of Farina (2000). The harmonic
**magnitudes** and the THD are correct (the Chebyshev oracle passes
identically), but the sweep's `-1` phase term breaks the time-shift
equivalence, so the phases of `H2..HN` depend on the excitation and should
be ignored; the band of every `Hn` is also capped at `f2` by the inverse
filter.

```python
from phonometry import sweep_signal, swept_sine_distortion

x = sweep_signal(fs, f1, f2, seconds)          # the ISO 18233 ESS
res = swept_sine_distortion(y, fs, f1, f2, seconds, method="farina")
```

Sizing rules for both methods: the closest pair of arrivals is spaced
`L·ln(N/(N-1))` seconds, so the per-order window (`ir_length`, default the
largest power of two that fits, capped at 8192 samples) must not exceed
it: lengthen the sweep or lower `n_harmonics` for reverberant systems whose
tails need longer windows. Keep `n_harmonics·f2` below Nyquist: distortion
products above it fold back in any real recording. The analysis is
referenced to the excitation `amplitude`, so `H1` is the linear gain and
the THD is level-referenced exactly as driven.

## 4. Phase utilities: minimum phase, group delay, excess phase

For a causal, stable, minimum-phase system the log-magnitude and phase of
the frequency response are a Hilbert-transform pair (Bendat & Piersol,
Sec. 13.1.4): the phase is fully determined by `|H(f)|`. The
`phonometry.metrology` utilities compute that reconstruction with the real
cepstrum and decompose any measured response into its invertible and
all-pass parts:

```python
import numpy as np
from phonometry import (
    excess_phase, group_delay, minimum_phase, phase_decomposition,
)

H = np.fft.rfft(ir)                    # one-sided response, DC..Nyquist
h_min = minimum_phase(np.abs(H))       # phase from the magnitude alone
tau_g = group_delay(H, fs)             # -(1/2pi) dphi/df, seconds
phi_x = excess_phase(H)                # unwrap(arg H) - phi_min

res = phase_decomposition(H, fs)       # everything on one axis
res.excess_group_delay                 # the all-pass part, in seconds
res.plot()                             # magnitude, phases, group delays
```

The decomposition `H = H_min · H_ap` splits what an equalizer can invert
(`H_min`, minimum phase, causal and causally invertible) from what it never
can (`H_ap`, the all-pass excess: latency plus non-minimum-phase zeros such
as reflections). The excess phase is `0` for a minimum-phase response and
exactly `-2πf·t0` for a pure latency `t0`; its group delay reads the
latency in seconds.

Numerical contract, pinned by the tests: on a strictly minimum-phase biquad
sampled on a dense grid the reconstructed phase matches the true phase to
better than `1e-12` rad; the group delay of a first-order allpass matches
the closed form `(1-a²)/(1+2a·cosω+a²)` to `1e-5` samples; the excess group
delay of a delayed biquad returns the delay to `1e-6` samples. The
precautions are documented with the API: the response must be sampled
uniformly from DC to Nyquist inclusive (the `rfft` layout) and densely
enough that the underlying impulse response fits the implied record;
magnitude zeros (band-pass edges, notch bottoms) are floored and are not
representable by a minimum-phase system; the `oversample` factor
(trigonometric interpolation of the magnitude before the cepstrum)
mitigates the cepstral aliasing that near-circle zeros cause on coarse
grids.

## Relation to other tools

- [`impulse_response`](https://jmrplens.github.io/phonometry/guides/room-acoustics/) recovers the *linear* IR from the
  same sweep recording and simply discards the negative-time distortion
  products; `swept_sine_distortion` is the tool that reads them.
- [`thd` / `harmonic_analysis`](electroacoustics.md) measure distortion
  from a steady tone at one frequency; the sweep separator returns the same
  ratios as a continuous function of frequency, from one measurement.
- The phase utilities operate on any one-sided *complex* response: an
  `rfft` of a measured IR, or the `response` of a
  [`transfer_function`](electroacoustics.md) estimate on a uniform grid.
  `minimum_phase` alone also accepts a plain magnitude array, e.g. a
  design target for equalization.

## References

- Farina, A. (2000). Simultaneous measurement of impulse response and
  distortion with a swept-sine technique. *108th AES Convention*, Paris,
  preprint 5093. The exponential-sweep deconvolution and the `L·ln(n)`
  harmonic separation.
- Novak, A., Lotton, P., & Simon, L. (2015). Synchronized swept-sine:
  Theory, application and implementation. *Journal of the Audio Engineering
  Society*, 63(10), 786-798.
  [doi:10.17743/jaes.2015.0071](https://doi.org/10.17743/jaes.2015.0071).
  The synchronization condition, the analytic inverse-filter spectrum and
  the fractional-delay separation.
- Müller, S., & Massarani, P. (2001). Transfer-function measurement with
  sweeps. *Journal of the Audio Engineering Society*, 49(6), 443-471.
  The sweep-measurement monograph behind the practice notes (inverse
  filters, fades, distortion rejection).
- Bendat, J. S., & Piersol, A. G. (2010). *Random Data: Analysis and
  Measurement Procedures* (4th ed.). Wiley. ISBN 978-0-470-24877-5.
  [doi:10.1002/9781118032428](https://doi.org/10.1002/9781118032428).
  Section 13.1.4: the Hilbert relation between log-magnitude and phase
  behind the minimum-phase reconstruction.

---


<!-- source: docs/block-processing.md | canonical: https://jmrplens.github.io/phonometry/guides/block-processing/ -->

# Block Processing

Some measurements never fit in memory: an hour-long environmental recording,
a live monitor that must report levels while the microphone is still
capturing, or an embedded logger that only ever sees one buffer at a time. In
all of these the signal has to be processed block by block, and the filters
must behave exactly as if they had seen the whole signal at once.

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/diagram_block_processing_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/diagram_block_processing.svg" alt="Two lanes comparing block processing with the filter state carried across blocks, giving one continuous envelope, versus reset each block, where the envelope restarts from zero at every seam" width="86%"></picture>

The `OctaveFilterBank`, `WeightingFilter` (for A, C, or Z-weighting) and
`TimeWeighting` classes support block (streaming) processing: the internal
filter state is carried between calls, so concatenated block outputs match a
single full-signal pass.

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/block_processing_continuity_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/block_processing_continuity.svg" alt="Stateful block processing matching the continuous result versus independent blocks restarting the filter transient at each boundary" width="80%"></picture>

*With `stateful=True` the concatenated block outputs match the continuous
result exactly; without state, every block boundary restarts the filter
transient.*

<details>
<summary>Show the code for this figure</summary>

```python
import matplotlib.pyplot as plt
import numpy as np
from phonometry import metrology

# 1 kHz octave band: four stateful blocks vs one continuous pass
fs, block = 8000, 1000
rng = np.random.default_rng(42)
x = rng.standard_normal(4 * block)
t = np.arange(x.size) / fs

bank = metrology.OctaveFilterBank(fs, fraction=1, limits=[900, 1100],
                                  stateful=True, resample=False)
streamed = np.concatenate([
    bank.filter(x[i * block:(i + 1) * block], sigbands=True,
                detrend=False, calculate_level=False)[2][0]
    for i in range(4)
])
offline = metrology.OctaveFilterBank(fs, fraction=1, limits=[900, 1100],
                                     resample=False).filter(
    x, sigbands=True, detrend=False, calculate_level=False)[2][0]
print(np.max(np.abs(streamed - offline)))     # 0.0 (bit-exact)

fig, ax = plt.subplots(figsize=(9, 4.5))
ax.plot(t, offline, linewidth=2.5, alpha=0.35, label="Continuous (whole signal)")
ax.plot(t, streamed, label="Stateful blocks (state carried)")
ax.axvline(block / fs, color="gray", linestyle=":", label="Block boundary")
ax.set(xlim=(0.11, 0.17), xlabel="Time [s]", ylabel="Amplitude")
ax.legend()
plt.show()
```

</details>

Create a stateful filter bank with `stateful=True`. The internal state is
zero-initialized by default but may be initialized for step-response
steady-state (like `scipy.signal.sosfilt_zi`) with `steady_ic=True`. The
options that must be disabled in stateful mode are summarized in the
[constraints table](#stateful-mode-constraints) at the end of this guide.

## Example

This example streams a WAV file with [`soundfile`](https://pysoundfile.readthedocs.io/),
an optional dependency (`pip install soundfile`). Any block source works just as
well: `scipy.io.wavfile` plus manual slicing, or a live capture callback.

```python
import soundfile as sf
from phonometry import metrology

fs = 48000
octave_filter = metrology.OctaveFilterBank(fs, 1, stateful=True, resample=False)
afilter = metrology.WeightingFilter(fs, "A", stateful=True)

for block in sf.blocks("measurement.wav", blocksize=256, overlap=0):

    # Apply A-filter
    weighted = afilter.filter(block)

    # Split into octave bands
    block_spl, _, block_output = octave_filter.filter(weighted, sigbands=True, detrend=False)

    # further signal processing
    ...
```

## Time weighting across blocks

Use the `TimeWeighting` class (state carried automatically):

```python
from phonometry import metrology

tw = metrology.TimeWeighting(fs, mode="fast")
# audio_blocks: successive frames of your microphone recording (Pa),
#   e.g. from sf.blocks("measurement.wav", ...) as in the block above.
for block in audio_blocks:
    envelope = tw.process(block)
```

Or manage the state yourself with the functional API; see
[Time Weighting](https://jmrplens.github.io/phonometry/guides/time-weighting/#6-block-processing).

## Multichannel state

All stateful classes handle multichannel input `(channels, samples)`: the state
is allocated lazily on the first call to match the channel count, and reallocated
if the channel count changes (e.g. switching from stereo to mono resets the state).

## What the carried state is

Every filter in the library runs as a cascade of second-order sections in
transposed direct form II. For one section with coefficients
$(b_0, b_1, b_2, a_1, a_2)$:

$$
y[n] = b_0\,x[n] + z_1[n-1], \qquad
z_1[n] = b_1\,x[n] - a_1\,y[n] + z_2[n-1], \qquad
z_2[n] = b_2\,x[n] - a_2\,y[n]
$$

The pair $(z_1, z_2)$ per section is the filter's *entire* memory of the
past. `stateful=True` stores these values when a block ends and restores
them when the next begins, so the recursion cannot tell where one buffer
stopped and the next started: the concatenated output equals the
single-pass output exactly, not approximately. The `TimeWeighting` detector
carries even less, just its last envelope value $y[n-1]$, which is the same
value the functional API hands back as `initial_state` (see
[Time Weighting](https://jmrplens.github.io/phonometry/guides/time-weighting/#6-block-processing)).

### Streaming pitfalls

- **Per-block preprocessing creates seams.** Anything computed from a single
  block that should be global, like detrending (removing the block's own
  mean) or normalization, gives each block a slightly different operation:
  the outputs no longer concatenate into the continuous result. This is why
  `detrend` must be `False` in stateful mode.
- **Not every metric streams.** Energy metrics accumulate cleanly (a
  running Leq is a running energy sum), but rank statistics do not: the L90
  of a recording is not any combination of per-block L90 values. Stream the
  *envelope* and compute percentiles once, on the pooled result.
- **The first block still carries the onset transient.** State starts at
  rest, so the filter settles during the first instants exactly as in a
  single pass. Use `steady_ic=True` to start in step-response steady state,
  or discard the settling time once (not once per block).
- **One stream per object.** A stateful instance holds the memory of one
  signal; feeding two interleaved streams through it corrupts both. Create
  one filter object per stream: the design cost is paid once at
  construction, not per block.

## Real-time level meter pattern

The canonical streaming loop weights, envelopes and reports block by block,
carrying all state across calls:

```python
import numpy as np
from phonometry import metrology

fs, block = 48000, 4800  # 100 ms blocks
aw = metrology.WeightingFilter(fs, "A", stateful=True)
env = metrology.TimeWeighting(fs, mode="fast")  # the class is inherently stateful

for x in audio_stream(block):            # your capture callback
    y = env.process(aw.filter(x))
    spl = 10 * np.log10(y[..., -1] / (2e-5) ** 2)  # instantaneous LAF
    display(spl)
```

### Stateful-mode constraints

| Option | Stateful behavior | Why |
| :--- | :--- | :--- |
| `detrend` | must be `False` | Per-block detrending creates boundary discontinuities |
| `resample` | must be `False` | The resampler is not stateful |
| `zero_phase` | unsupported | Forward-backward filtering needs the whole signal |
| `high_accuracy` (weighting) | resolves to `False` by default (the legacy bilinear design, see [Frequency Weighting](https://jmrplens.github.io/phonometry/guides/weighting/)); explicitly passing `True` raises `ValueError` | The polyphase resampling inside is block-incompatible |
| `steady_ic` | optional | Starts the filters in step-response steady state |

## References

- Oppenheim, A. V., & Schafer, R. W. (2010). *Discrete-time signal processing*
  (3rd ed.). Pearson. ISBN 978-0-13-198842-2.
  [Open Library record](https://openlibrary.org/isbn/9780131988422).
  The direct-form filter structures and state recursion behind the carried
  state equation above.

## Standards

IEC 61260-1:2014, *Electroacoustics — Octave-band and
fractional-octave-band filters — Part 1: Specifications*, and IEC 61672-1:2013,
*Electroacoustics — Sound level meters — Part 1: Specifications* — block
processing adds no normative content of its own: the streamed filters are the
same designs those standards govern (see [Filter Banks](https://jmrplens.github.io/phonometry/guides/filter-banks/),
[Frequency Weighting](https://jmrplens.github.io/phonometry/guides/weighting/) and [Time Weighting](https://jmrplens.github.io/phonometry/guides/time-weighting/)),
and carrying the internal filter state across blocks is exactly what makes the
concatenated output identical to a single full-signal pass, so every class and
tolerance claim of the underlying pages holds unchanged in streaming use.

---


<!-- source: docs/multichannel.md | canonical: https://jmrplens.github.io/phonometry/guides/multichannel/ -->

# Multichannel and Performance

## Multichannel Support

phonometry natively supports multichannel signals (e.g., Stereo, 5.1,
Microphone Arrays) using **fully vectorized operations**. Input arrays of shape
`(N_channels, N_samples)` are processed in parallel, offering significant
performance gains over iterative loops.

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/signal_response_multichannel_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/signal_response_multichannel.svg" alt="Stereo analysis: pink noise and logarithmic sweep resolved per channel in one-third-octave bands" width="80%"></picture>

*Simultaneous analysis of a Stereo signal: Left Channel (Pink Noise) vs Right
Channel (Log Sine Sweep).*

<details>
<summary>Show the code for this figure</summary>

```python
import matplotlib.pyplot as plt
import numpy as np
from scipy.signal import chirp
from phonometry import metrology

# Stereo test signal: pink noise left, logarithmic sine sweep right
fs, duration = 48000, 5
t = np.linspace(0, duration, fs * duration, endpoint=False)
rng = np.random.default_rng(42)
spec = np.fft.rfft(rng.standard_normal(t.size))
spec[1:] /= np.sqrt(np.arange(1, spec.size))   # 1/f shaping: pink noise
left = np.fft.irfft(spec, t.size)
right = chirp(t, f0=50, t1=duration, f1=10000, method="logarithmic")

x = np.stack([left, right])                    # (2, n_samples)
spl, freq = metrology.octave_filter(x, fs, fraction=3, limits=[20, 20000])

fig, axes = plt.subplots(2, 1, figsize=(9, 7), sharex=True)
for ax, levels, name in zip(axes, spl, ["Left: pink noise", "Right: log sweep"]):
    ax.semilogx(freq, levels, marker="o", label=name)
    ax.set_ylabel("Level [dB]")
    ax.grid(True, which="both", alpha=0.3)
    ax.legend()
axes[-1].set_xlabel("Frequency [Hz]")
plt.show()
```

</details>

The convention is consistent across the whole library: time is always the
**last axis**. This applies to `octave_filter`, `OctaveFilterBank`,
`weighting_filter`, `time_weighting`, `leq`, `laeq`, `ln_levels` and
`spectrogram`.

```python
import numpy as np
from phonometry import metrology

# Two calibrated channels in Pa so the guide runs standalone
fs = 48000
t = np.arange(fs) / fs
left = 0.2 * np.sin(2 * np.pi * 1000 * t)
right = 0.1 * np.sin(2 * np.pi * 500 * t)

stereo = np.stack([left, right])          # (2, n_samples)
spl, freq = metrology.octave_filter(stereo, fs, fraction=3)
# spl has shape (2, n_bands): one row per channel
```

## Accepted shapes, at a glance

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/diagram_multichannel_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/diagram_multichannel.svg" alt="Array-shape flow: a 1-D (samples,) input reduces to a scalar and a 2-D (channels, samples) input reduces to (channels,), because the operation runs along the last axis while the channel axis is preserved" width="80%"></picture>

| Input | Interpreted as | Typical output |
| :--- | :--- | :--- |
| `(n,)` 1D array | one channel | scalar level / `(bands,)` |
| `(ch, n)` 2D array | `ch` channels, `n` samples each | `(ch,)` levels / `(ch, bands)` |
| list of floats | one channel (converted) | as 1D |
| `(ch, n)` into `spectrogram` | multichannel STFT-style | `(ch, bands, frames)` |

Everything vectorizes across the leading channel axis: one filter design is
applied to all channels in a single SciPy call, so 8 channels cost far less
than 8 separate runs. Convention: **channels first**, like most DSP code
(`soundfile` returns `(n, ch)`: transpose with `x.T`).

## Per-channel semantics

Multichannel processing is strictly per channel: nothing is ever mixed,
summed or averaged across the channel axis. Three consequences worth
spelling out:

- **Combining channels is your decision.** Levels come back one per
  channel. If you need an array-average level (for instance the
  position-averaged levels of the room-acoustics standards), combine
  energies yourself: `10 * np.log10(np.mean(10 ** (spl / 10), axis=0))`,
  never the arithmetic mean of the dB values (see
  [Levels](https://jmrplens.github.io/phonometry/guides/levels/) for why).
- **One `calibration_factor` means one sensitivity.** The scalar factor
  multiplies every channel, which is correct only if all channels share the
  same sensitivity. For an array of microphones with individual
  calibrations, scale the rows first, `x * factors[:, None]`, and leave
  `calibration_factor` at 1.
- **Stateful classes keep one state per channel.** In block processing the
  state array matches the channel count and a change of channel count
  resets it; see [Block Processing](https://jmrplens.github.io/phonometry/guides/block-processing/).

## Performance: Vectorization and caching

The `OctaveFilterBank` class is highly optimized for real-time and batch
processing. It uses NumPy vectorization to handle multichannel audio arrays
(e.g., 64-channel microphone arrays) without explicit Python loops, ensuring
maximum throughput.

```python
import numpy as np
from phonometry import metrology

# Two calibrated channels in Pa so the guide runs standalone
fs = 48000
t = np.arange(fs) / fs
left = 0.2 * np.sin(2 * np.pi * 1000 * t)
right = 0.1 * np.sin(2 * np.pi * 500 * t)
stereo = np.stack([left, right])          # (2, n_samples)

bank = metrology.OctaveFilterBank(fs=48000, fraction=3, filter_type='butter')

# Access computed properties
# bank.freq (center), bank.freq_d (lower), bank.freq_u (upper), bank.sos (coefficients)

# Process multiple signals efficiently
stream = [stereo]                 # your sequence of multichannel frames
for frame in stream:
    # detrend=True (default) removes DC offset to improve low-freq accuracy
    spl, freq = bank.filter(frame, detrend=True)
```

Additional performance notes:

- **Design cache**: `octave_filter()` reuses filter bank designs across calls
  with identical parameters (LRU cache, 32 entries), so calling it in a loop
  does not redesign the bank each time. `OctaveFilterBank` gives you explicit
  control over the design lifetime.
- **Multirate decimation**: low-frequency bands are filtered at a decimated
  rate, which is both faster and numerically more stable (see
  [Theory](https://jmrplens.github.io/phonometry/reference/theory/signal-analysis/)).
- **Optional numba**: the `impulse` time weighting kernel is JIT-compiled when
  numba is installed (`pip install phonometry[perf]`).

## References

- International Electrotechnical Commission. (2014). *Electroacoustics —
  Octave-band and fractional-octave-band filters — Part 1: Specifications*
  (IEC 61260-1:2014).
  [IEC webstore](https://webstore.iec.ch/en/publication/5063).
  The band definitions each channel is filtered with; the multichannel path
  batches them unchanged.
- International Electrotechnical Commission. (2013). *Electroacoustics —
  Sound level meters — Part 1: Specifications* (IEC 61672-1:2013).
  [IEC webstore](https://webstore.iec.ch/en/publication/5708).
  The weighting and time-integration semantics applied per channel.

## Standards

IEC 61260-1:2014, *Electroacoustics — Octave-band and
fractional-octave-band filters — Part 1: Specifications*, and IEC 61672-1:2013,
*Electroacoustics — Sound level meters — Part 1: Specifications* —
multichannel support adds no normative content of its own: each channel is
filtered, weighted and time-integrated exactly as the single-channel standards
prescribe (see [Filter Banks](https://jmrplens.github.io/phonometry/guides/filter-banks/) and [Levels](https://jmrplens.github.io/phonometry/guides/levels/)); the
vectorization only batches the computation across the channel axis.

---


<!-- source: docs/api-reference.md | canonical: https://jmrplens.github.io/phonometry/reference/api/ -->

# API Reference

All core functionality lives in fifteen domain subpackages; every public name
is also re-exported by the top-level `phonometry` package.

> **Note.** This page is the curated quick table for the GitHub/PyPI audience:
> one row per public name, kept complete by a CI gate
> (`scripts/check_api_reference.py`). The full generated reference, with every
> signature, parameter table and cross-link, lives on the site:
> [phonometry API reference](https://jmrplens.github.io/phonometry/reference/api/).

## Namespaces

The library is organized into fifteen domain subpackages, and importing the
domain namespace is the primary form used throughout the documentation:

```python
from phonometry import aircraft

contour = aircraft.noise_contour(path, powers, distances, sel, lmax, x=gx, y=gy)
```

| Subpackage | Scope |
| :--- | :--- |
| `phonometry.metrology` | Filter banks, weighting and time weighting, levels, calibration, IEC verifiers, GUM uncertainty |
| `phonometry.psychoacoustics` | Loudness (Zwicker, ECMA, Moore-Glasberg), sharpness, tonality, roughness, fluctuation strength, annoyance, tonal audibility |
| `phonometry.hearing` | Hearing threshold, NIHL, occupational exposure, SII, STI |
| `phonometry.emission` | Sound power (ISO 3740 family), sound intensity, vibration-based power |
| `phonometry.room` | Room acoustics (ISO 3382), impulse responses, open-plan, room-noise criteria, reverberation prediction, EN 12354-6 |
| `phonometry.materials` | Absorption (ISO 354/11654), impedance tube, airflow resistance, scattering/diffusion, road absorption, dynamic stiffness |
| `phonometry.building` | Sound insulation measurement and prediction (EN 12354, ISO 717/16283/10140/15186/10052/10848), structure-borne sound |
| `phonometry.vibration` | Human vibration (ISO 2631/5349/8041), multiple shocks, mobility (ISO 7626), transfer stiffness (ISO 10846) |
| `phonometry.environmental` | Rating levels, ISO 1996-2 measurement, outdoor propagation (ISO 9613), atmospheric refraction (ray tracing and the parabolic equation), wind-turbine noise, impulsive prominence |
| `phonometry.aircraft` | EPNL (ICAO Annex 16), SAE ARP 5534 absorption, airport contours (ECAC Doc 29), rotorcraft (ECAC Doc 32) |
| `phonometry.underwater` | ISO 18405/17208/18406 levels, propagation, sound speed, sonar equation, seabed, ambient and ship-traffic noise, numerical solvers |
| `phonometry.electroacoustics` | Distortion (IEC 60268-3 / AES17), transfer function and coherence, radiating piston |
| `phonometry.noise_control` | Reactive silencers (four-pole method), HVAC duct attenuation, machine-enclosure insertion loss |
| `phonometry.broadcast` | Programme loudness and true peak (ITU-R BS.1770-5, EBU R 128 with Tech 3341/3342) |
| `phonometry.simulation` | 2D acoustic FDTD wave simulation (staggered grid, sources, probes, impedance boundaries, obstacles) |

Every name in the table below is also re-exported at the top level, so
`from phonometry import <name>` works for every row. The pre-3.2 flat module
paths (for example `phonometry.insulation`) keep importing for one deprecation
cycle and warn on use; they are removed in 4.0.

| Name | Type | Description (Inputs) | Usage Snippet (Outputs) |
| :--- | :--- | :--- | :--- |
| `octave_filter` | `function` | **High-level analysis.**<br>• `x`: Signal array<br>• `fs`: Sample rate [Hz]<br>• `fraction`: 1, 3, etc. (Default: 1)<br>• `order`: Filter order (Default: 6)<br>• `limits`: [f_min, f_max] (Default: [12, 20000])<br>• `filter_type`: 'butter', 'cheby1', 'cheby2', 'ellip', 'bessel' (Default: 'butter')<br>• `sigbands`: Return time signals (Default: False)<br>• `detrend`: Remove DC offset (Default: True)<br>• `calibration_factor`: Sensitivity multiplier (Default: 1.0)<br>• `dbfs`: Output in dBFS instead of dB SPL (Default: False)<br>• `mode`: 'rms' or 'peak' (Default: 'rms')<br>• `nominal`: IEC 61260-1 nominal labels (Default: False)<br>• `show`: Plot response (Default: False)<br>• `plot_file`: Path to save plot (Default: None)<br>• `ripple`: Passband ripple [dB] (for cheby1/ellip)<br>• `attenuation`: Stopband atten. [dB] (Default: 72; for cheby2/ellip; cheby2 needs >= 70 dB for class 1) | `spl, freq = octave_filter(x, fs, ...)`<br>• `spl`: levels [dB]<br>• `freq`: frequencies [Hz]<br><br>**With `sigbands=True`:**<br>`spl, freq, xb = octave_filter(x, fs, sigbands=True)`<br>• `xb`: List of filtered signals (one per band)<br><br>**Calibrated usage:**<br>`spl, f = octave_filter(x, fs, calibration_factor=0.05)` |
| `OctaveFilterBank` | `class` | **Efficient bank implementation.**<br>• `fs`: Sample rate [Hz]<br>• `fraction`: 1, 3, etc.<br>• `order`: Filter order<br>• `limits`: [f_min, f_max] (Default: [12, 20000])<br>• `filter_type`: Architecture name<br>• `show`: Plot response (Default: False)<br>• `plot_file`: Path to save the plot (Default: None)<br>• `calibration_factor`: Sensitivity multiplier<br>• `dbfs`: Use dBFS (Default: False)<br>• `stateful`: Carry filter state between calls (Default: False)<br>• `steady_ic`: Steady-state initial conditions (Default: False)<br>• `resample`: Multirate decimation (Default: True)<br>• `ripple`: Passband ripple [dB]<br>• `attenuation`: Stopband attenuation [dB] (Default: 72; cheby2 needs >= 70 dB for class 1) | `bank = OctaveFilterBank(fs=48000, fraction=3, order=6, filter_type='butter')`<br>`spl, f = bank.filter(x, sigbands=False, mode='rms', detrend=True, zero_phase=False)`<br><br>• `bank`: Instance of the filter bank<br>• `bank.freq` / `bank.freq_d` / `bank.freq_u` / `bank.sos`: computed properties |
| `OctaveFilterBank.spectrogram` | `method` | **Band levels over time.**<br>• `x`: Signal array (1D or 2D)<br>• `window_time`: Window length [s] (Default: 0.125)<br>• `overlap`: Fraction in [0, 1) (Default: 0.5)<br>• `mode`: 'rms' or 'peak'<br>• `zero_phase`: Group-delay-free frames (Default: False) | `levels, freq, times = bank.spectrogram(x)`<br><br>• `levels`: (bands, frames) or (channels, bands, frames)<br>• `times`: window centers [s] |
| `weighting_filter` | `function` | **Acoustic weighting.**<br>• `x`: Signal array<br>• `fs`: Sample rate [Hz]<br>• `curve`: 'A', 'C' (IEC 61672-1), 'B' (ANSI S1.4-1983, historical), 'D' (withdrawn IEC 537, aircraft), 'G' (ISO 7196 infrasound), 'AU' (IEC 61012) or 'Z' (Default: 'A')<br>• `high_accuracy`: IEC class 1 HF accuracy via internal oversampling (Default: True) | `y = weighting_filter(x, fs, curve='A')`<br><br>• `y`: weighted signal |
| `WeightingFilter` | `class` | **Reusable weighting filter.**<br>• `fs`: Sample rate [Hz]<br>• `curve`: 'A', 'B', 'C', 'D', 'G', 'AU' or 'Z'<br>• `stateful`: Block processing (Default: False)<br>• `steady_ic`: Steady-state initial conditions (Default: False)<br>• `high_accuracy`: Defaults to True except stateful | `wf = WeightingFilter(fs, 'A')`<br>`y = wf.filter(x)` |
| `time_weighting` | `function` | **Energy capture.**<br>• `x`: Raw signal array (squared internally; time is the last axis)<br>• `fs`: Sample rate [Hz]<br>• `mode`: 'fast', 'slow', or 'impulse'<br>• `initial_state`: None, 'zero', 'first', scalar, or array (Default: None) | `env = time_weighting(x, fs, mode='fast')`<br><br>• `env`: energy envelope (Mean Square), same shape as `x` |
| `TimeWeighting` | `class` | **Stateful time weighting.**<br>• `fs`: Sample rate [Hz]<br>• `mode`: 'fast', 'slow', 'impulse' (Default: 'fast') | `tw = TimeWeighting(fs, mode='fast')`<br>`env = tw.process(block)` per block<br>`tw.reset()` to start over |
| `leq` | `function` | **Equivalent level (Leq).**<br>• `x`: Signal array (1D or 2D)<br>• `calibration_factor`: Sensitivity multiplier (Default: 1.0)<br>• `dbfs`: Output in dBFS (Default: False) | `level = leq(x, calibration_factor=s)`<br><br>• `level`: Scalar (1D) or per-channel array (2D) |
| `laeq` | `function` | **A-weighted Leq (LAeq).**<br>• `x`: Signal array (1D or 2D)<br>• `fs`: Sample rate [Hz]<br>• `calibration_factor` / `dbfs`: as `leq` | `level = laeq(x, fs, calibration_factor=s)`<br><br>• `level`: Scalar (1D) or per-channel array (2D) |
| `ln_levels` | `function` | **Statistical levels (LN).**<br>• `x`: Signal array (1D or 2D)<br>• `fs`: Sample rate [Hz]<br>• `n`: Exceedance percentiles (Default: (10, 50, 90))<br>• `mode`: 'fast', 'slow', 'impulse' (Default: 'fast')<br>• `weighting`: 'A', 'C', 'G', 'Z' or None (Default: None)<br>• `calibration_factor` / `dbfs`: as `leq` | `stats = ln_levels(x, fs, n=(10, 50, 90), weighting='A')`<br><br>• `stats`: Dict `{10: L10, 50: L50, 90: L90}` |
| `lc_peak` | `function` | **C-weighted peak (LCpeak).**<br>• `x`: Signal array (1D or 2D)<br>• `fs`: Sample rate [Hz]<br>• `calibration_factor`: as `leq`<br>• `dbfs`: 0 dBFS = full-scale *peak* (amplitude 1.0), unlike the RMS reference of `leq`<br>• `oversample`: HF inter-sample peak recovery (Default: 8; 1 = on-grid, under-reads up to ~1.15 dB at 48 kHz) | `peak = lc_peak(x, fs)`<br><br>• IEC 61672-1 §5.13; verified against Table 5 |
| `sel` | `function` | **Sound exposure level (SEL/LAE).**<br>• `x`: Whole-event signal (1D or 2D)<br>• `fs`: Sample rate [Hz]<br>• `weighting`: 'A', 'C', 'G', 'Z' or None (Default: None)<br>• `calibration_factor` / `dbfs`: as `leq` | `lae = sel(x, fs, weighting='A')`<br><br>• Event level normalized to 1 s |
| `sound_exposure` | `function` | **A-weighted exposure E (IEC 61252).**<br>• `x`: Signal (1D or 2D)<br>• `fs`: Sample rate [Hz]<br>• `duration_hours`: exposure period represented (Default: recording length)<br>• `calibration_factor` | `E = sound_exposure(x, fs, duration_hours=8)`<br><br>• `E`: Pa²·h |
| `lex_8h` | `function` | **Daily exposure LEX,8h / LEP,d.**<br>• Same params as `sound_exposure` | `lex = lex_8h(x, fs, duration_hours=4)`<br><br>• Normalized 8 h level [dB] |
| `loudness_zwicker` | `function` | **Zwicker loudness (ISO 532-1:2017).**<br>• `x`: Calibrated signal in Pa (1D)<br>• `fs`: Sample rate [Hz]<br>• `field`: 'free' or 'diffuse' (Default: 'free')<br>• `stationary`: Clause 5 stationary method (Default: False)<br>• `time_skip`: seconds excluded from the stationary mean square (Annex B.1 uses 0.2 s; Default: 0.0)<br>• `calibration_factor`: Digital units to Pa (Default: 1.0) | `res = loudness_zwicker(x, fs)`<br><br>• `ZwickerLoudness`; time-varying runs add N5/N10 and the 500 Hz N(t) trace |
| `loudness_zwicker_from_spectrum` | `function` | **Stationary loudness from band levels.**<br>• `levels`: 28 one-third-octave levels, 25 Hz-12.5 kHz [dB SPL]<br>• `field`: 'free' or 'diffuse' (Default: 'free') | `res = loudness_zwicker_from_spectrum(levels)`<br><br>• `res.loudness` [sone], `res.loudness_level` [phon] |
| `ZwickerLoudness` | `dataclass` | **Loudness result.**<br>• `loudness`: N [sone]<br>• `loudness_level`: LN [phon]<br>• `specific`: N′(z), 240 bins of 0.1 Bark<br>• `n5` / `n10`: percentile loudness (time-varying only)<br>• `time` / `loudness_vs_time`: 500 Hz trace<br>• `field`: sound field assumed ('free'/'diffuse') | `res.loudness, res.n5`<br><br>• Time-varying fields are `None` for stationary results |
| `sharpness_din` | `function` | **Sharpness in acum (DIN 45692).**<br>• `x`: Signal (1D)<br>• `fs`: Sample rate [Hz]<br>• `field`: 'free' or 'diffuse' (Default: 'free')<br>• `method`: 'din', 'aures' or 'bismarck' (Default: 'din')<br>• `calibration_factor`: Digital units to Pa (Default: 1.0) | `s = sharpness_din(x, fs)`<br><br>• `s`: sharpness [acum] |
| `sharpness_din_from_specific` | `function` | **Sharpness from a specific-loudness pattern.**<br>• `specific`: N′(z), 240 values at 0.1 Bark (e.g. `ZwickerLoudness.specific`)<br>• `method`: 'din', 'aures' or 'bismarck' (Default: 'din') | `s = sharpness_din_from_specific(res.specific)`<br><br>• `s`: sharpness [acum] |
| `loudness_moore_glasberg` | `function` | **Moore-Glasberg loudness (ISO 532-2:2017).**<br>• `x`: Calibrated signal in Pa (1D)<br>• `fs`: Sample rate [Hz]<br>• `field`: 'free', 'diffuse' or 'eardrum' (Default: 'free')<br>• `presentation`: 'binaural'/'diotic' or 'monaural' (Default: 'binaural') | `res = loudness_moore_glasberg(x, fs)`<br><br>• `MooreGlasbergLoudness` (roex excitation pattern) |
| `loudness_moore_glasberg_from_spectrum` | `function` | **Exact loudness from sinusoidal components (ISO 532-2 §5.2/5.4).**<br>• `components`: sequence of (frequency [Hz], level [dB SPL]) pairs<br>• `field`: 'free'/'diffuse'/'eardrum' (Default: 'free')<br>• `presentation`: (Default: 'binaural') | `res = loudness_moore_glasberg_from_spectrum([(1000.0, 40.0)])`<br><br>• 1 kHz/40 dB → 1.000 sone |
| `loudness_moore_glasberg_from_third_octave` | `function` | **Loudness from 29 one-third-octave band levels (ISO 532-2 §5.5).**<br>• `band_levels`: 29 levels, 25 Hz–16 kHz [dB SPL]<br>• `field` / `presentation`: as above | `res = loudness_moore_glasberg_from_third_octave(levels)`<br><br>• `MooreGlasbergLoudness` |
| `MooreGlasbergLoudness` | `dataclass` | **Moore-Glasberg loudness result.**<br>• `loudness`: N [sone]<br>• `loudness_level`: LN [phon]<br>• `specific`: N′(i), 372 bins of 0.1 Cam<br>• `erb_number` / `centre_frequencies`: Cam / Hz axes<br>• `field` / `presentation`<br>• `.plot()`: specific loudness N′(i) over Cam | `res.loudness, res.loudness_level` |
| `loudness_moore_glasberg_time` | `function` | **Time-varying loudness (ISO 532-3:2023).**<br>• `signal`: Calibrated Pa; 1D (diotic) or (n, 2) ears<br>• `fs`: Sample rate [Hz]<br>• `field`: 'free'/'diffuse'/'eardrum' (Default: 'free')<br>• `presentation`: (Default: 'binaural')<br>• `percentiles`: exceedance % (Default: (1,5,10,50,90,95)) | `res = loudness_moore_glasberg_time(x, fs)`<br><br>• `MooreGlasbergTimeVaryingLoudness` |
| `MooreGlasbergTimeVaryingLoudness` | `dataclass` | **Time-varying loudness result.**<br>• `time`: 1 ms grid [s]<br>• `short_term_loudness` / `long_term_loudness`: S′(t)/S″(t) [sone]<br>• `short_term_loudness_level` / `long_term_loudness_level` [phon]<br>• `n_max` / `loudness_level_max`: peak long-term loudness<br>• `percentiles`: dict {percent: sone}<br>• `.plot()`: STL and LTL vs time | `res.n_max, res.percentiles[5.0]` |
| `loudness_ecma` | `function` | **Sottek Hearing Model loudness (ECMA-418-2:2025).**<br>• `signal_in`: Calibrated signal in Pa (1D)<br>• `fs`: Sample rate [Hz] (resampled to 48 kHz)<br>• `field`: 'free' or 'diffuse' (Default: 'free') | `res = loudness_ecma(x, fs)`<br><br>• `EcmaLoudness`; 1 kHz/40 dB ≈ 1 sone_HMS |
| `EcmaLoudness` | `dataclass` | **Sottek loudness result.**<br>• `loudness`: N [sone_HMS]<br>• `specific_loudness`: N′(z), 53 Bark_HMS bands<br>• `bark` / `centre_frequencies`: z / Hz axes<br>• `time` / `loudness_vs_time`: N(l) at 187.5 Hz<br>• `field`<br>• `.plot()`: N′(z) + N(l) | `res.loudness, res.specific_loudness` |
| `tonality_ecma` | `function` | **Sottek tonality (ECMA-418-2:2025).**<br>• `signal_in`: Calibrated signal in Pa (1D)<br>• `fs`: Sample rate [Hz] (resampled to 48 kHz)<br>• `field`: 'free' or 'diffuse' (Default: 'free')<br>• `f_low` / `f_high`: optional user band [Hz] (Default: None) | `res = tonality_ecma(x, fs)`<br><br>• `EcmaTonality`; 1 kHz/40 dB ≈ 1 tu_HMS |
| `EcmaTonality` | `dataclass` | **Sottek tonality result.**<br>• `tonality`: T [tu_HMS]<br>• `specific_tonality`: T′(z), 53 bands<br>• `tonal_frequencies`: f_ton,z per band [Hz]<br>• `bark` / `centre_frequencies`<br>• `time` / `tonality_vs_time` / `tonal_frequency_vs_time`: T(l), f_ton(l)<br>• `field`<br>• `.plot()`: T′(z) + T(l) | `res.tonality, res.tonal_frequencies` |
| `roughness_ecma` | `function` | **Sottek roughness (ECMA-418-2:2025), new capability.**<br>• `signal_in`: Calibrated signal in Pa (1D)<br>• `fs`: Sample rate [Hz] (resampled to 48 kHz)<br>• `field`: 'free' or 'diffuse' (Default: 'free') | `res = roughness_ecma(x, fs)`<br><br>• `EcmaRoughness`; 1 kHz/70 Hz/100 %-AM/60 dB ≈ 1 asper |
| `EcmaRoughness` | `dataclass` | **Sottek roughness result.**<br>• `roughness`: R [asper] (90th percentile of R(l50))<br>• `specific_roughness`: R′(z), 53 bands<br>• `bark` / `centre_frequencies`<br>• `time` / `roughness_vs_time`: R(l50) at 50 Hz<br>• `specific_roughness_vs_time`: (n_times, 53)<br>• `field`<br>• `.plot()`: R(l50) + specific-roughness heatmap | `res.roughness, res.specific_roughness` |
| `fluctuation_strength_ecma` | `function` | **Sottek fluctuation strength (ECMA-418-2:2025 Clause 9), new capability.**<br>• `signal_in`: Calibrated signal in Pa (1D)<br>• `fs`: Sample rate [Hz] (resampled to 48 kHz)<br>• `field`: 'free' or 'diffuse' (Default: 'free') | `res = fluctuation_strength_ecma(x, fs)`<br><br>• `EcmaFluctuationStrength`; 1 kHz/4 Hz/100 %-AM/60 dB ≈ 1 vacil_HMS |
| `EcmaFluctuationStrength` | `dataclass` | **Sottek fluctuation-strength result.**<br>• `fluctuation_strength`: F [vacil_HMS] (90th percentile of F(l50))<br>• `specific_fluctuation_strength`: F′(z), 53 bands<br>• `bark` / `centre_frequencies`<br>• `time` / `fluctuation_strength_vs_time`: F(l50) at 50 Hz<br>• `specific_fluctuation_strength_vs_time`: (n_times, 53)<br>• `field`<br>• `.plot()`: F(l50) + specific-fluctuation-strength heatmap | `res.fluctuation_strength, res.specific_fluctuation_strength` |
| `psychoacoustic_annoyance` | `function` | **Psychoacoustic annoyance PA (Fastl & Zwicker Eqs 16.2–16.4), exact model.**<br>• `n5`: percentile loudness N5 [sone]<br>• `sharpness` S [acum]<br>• `fluctuation_strength` F [vacil]<br>• `roughness` R [asper] | `res = psychoacoustic_annoyance(30, 2.0, 0.5, 0.3)`<br><br>• `PsychoacousticAnnoyanceResult`; `res.annoyance == 37.0478` |
| `psychoacoustic_annoyance_from_signal` | `function` | **PA from a calibrated signal (convenience, engineering estimate).**<br>• `x`: Calibrated signal in Pa (1D)<br>• `fs`: Sample rate [Hz]<br>• `field`: 'free' or 'diffuse' (Default: 'free')<br>• `calibration_factor` (Default: 1.0)<br>Mixes Zwicker N5/S + Sottek R + Osses F. | `res = psychoacoustic_annoyance_from_signal(x, fs)`<br><br>• `PsychoacousticAnnoyanceResult` |
| `PsychoacousticAnnoyanceResult` | `dataclass` | **Psychoacoustic annoyance result.**<br>• `annoyance`: PA<br>• `n5` [sone] / `sharpness` [acum] / `fluctuation_strength` [vacil] / `roughness` [asper]: the four inputs<br>• `w_s`: sharpness weighting wS<br>• `w_fr`: roughness/fluctuation weighting wFR<br>• `.plot()`: term-contribution bar | `res.annoyance, res.w_s, res.w_fr` |
| `fluctuation_strength_am_noise` | `function` | **Fluctuation strength of AM broadband noise (Fastl & Zwicker Eq. 10.2), exact closed form.**<br>• `level_db` L [dB]<br>• `modulation_factor` m (0–1)<br>• `mod_frequency` fmod [Hz] | `fluctuation_strength_am_noise(60, 1.0, 4.0)  # 3.6943 vacil` |
| `fluctuation_strength` | `function` | **Fluctuation strength F (Osses 2016 signal model), no numeric standard, free-field.**<br>• `signal_in`: Calibrated signal in Pa (1D)<br>• `fs`: Sample rate [Hz] | `res = fluctuation_strength(x, fs)`<br><br>• `FluctuationStrengthResult`; 1 kHz/60 dB/m=1/4 Hz AM ≈ 1 vacil |
| `FluctuationStrengthResult` | `dataclass` | **Fluctuation strength result.**<br>• `fluctuation_strength`: F [vacil]<br>• `specific`: F′(z), 47 values<br>• `bark_axis`: z [Bark], 47 values<br>• `time_dependent`: F(t) per frame<br>• `.plot()`: specific F′(z) over the Bark axis | `res.fluctuation_strength, res.specific` |
| `equal_loudness_contour` | `function` | **ISO 226:2023 equal-loudness contour.**<br>• `phon`: Loudness level, 20-90 phon | `freqs, spl = equal_loudness_contour(40.0)`<br><br>• SPL at the 29 preferred frequencies |
| `equal_loudness_contours` | `function` | **ISO 226:2023 equal-loudness contour family (plottable).**<br>• `phons`: Loudness levels [phon] (Default: 20-90 in 10-phon steps)<br>• `frequencies`: Grid [Hz] (Default: the 29 preferred) | `res = equal_loudness_contours()`<br><br>• `EqualLoudnessContours`; `res.plot()` |
| `EqualLoudnessContours` | `dataclass` | **Equal-loudness contour family result.**<br>• `frequencies`: grid [Hz]<br>• `phons`: contour levels [phon]<br>• `contours`: SPL, `(len(phons), len(frequencies))` [dB] (`nan` where undefined)<br>• `threshold`: hearing threshold $T_f$ [dB]<br>• `.plot()`: the iconic ISO 226 chart | `res.contours, res.threshold` |
| `loudness_level` | `function` | **Loudness level of a pure tone (phon).**<br>• `spl`: Tone SPL [dB]<br>• `frequency`: One of the 29 Table 1 frequencies [Hz] | `phon = loudness_level(73.0, 63.0)` |
| `hearing_threshold` | `function` | **Threshold of hearing (ISO 226 Table 1).**<br>• (no parameters) | `freqs, tf = hearing_threshold()` |
| `tone_to_noise_ratio` | `function` | **Tone-to-noise ratio (ECMA-418-1 §11).**<br>• `x`: Signal (1D)<br>• `fs`: Sample rate [Hz]<br>• `tone_freq`: Tone to assess [Hz] (Default: highest peak)<br>• `resolution_hz`: FFT bin spacing (Default: 1.0) | `r = tone_to_noise_ratio(x, fs)`<br><br>• `ToneAssessment(frequency, ratio_db, criterion_db, prominent)` |
| `prominence_ratio` | `function` | **Prominence ratio (ECMA-418-1 §12).**<br>• Same params as `tone_to_noise_ratio` | `r = prominence_ratio(x, fs, tone_freq=1000)`<br><br>• `ToneAssessment(...)` |
| `ToneAssessment` | `dataclass` | **Tone prominence verdict.**<br>• `frequency`: Assessed tone [Hz]<br>• `ratio_db`: TNR or PR [dB]<br>• `criterion_db`: Prominence limit at that frequency [dB]<br>• `prominent`: bool | `r = tone_to_noise_ratio(x, fs)`<br>`if r.prominent: ...` |
| `tone_audibility` | `function` | **Tone audibility ΔL = LT−LG−av (ISO/PAS 20065 Formulae 12–14).**<br>• `tone_level` LT, `mean_narrowband_level` LS [dB]<br>• `tone_frequency` fT [Hz], `line_spacing` Δf [Hz] | `tone_audibility(67.96, 49.22, 137.3, 2.7)  # 5.01 dB` |
| `assess_tones` | `function` | **Audibility of a spectrum's tones (ISO/PAS 20065).**<br>• `tone_frequencies` [Hz], `tone_levels` LT, `mean_narrowband_levels` LS [dB]<br>• `line_spacing` Δf [Hz]<br>• `extended_uncertainties`: attach the Clause 5.4 U per tone (Default: True) | `res = assess_tones(fT, LT, LS, 2.7)`<br><br>• `ToneAudibilityResult` |
| `critical_bandwidth_engineering` | `function` | **Critical bandwidth Δfc about a tone (Formula 2).**<br>• `tone_frequency` fT [Hz] | `critical_bandwidth_engineering(137.3)  # 101.36 Hz` |
| `critical_band_corners` | `function` | **Corner frequencies (f₁, f₂) of the critical band (Formulae 3–5).**<br>• `tone_frequency` fT [Hz] | `f1, f2 = critical_band_corners(137.3)` |
| `critical_band_level` | `function` | **Masking-noise level LG = LS+10lg(Δfc/Δf) (Formula 12).**<br>• `mean_narrowband_level` LS [dB], `tone_frequency` fT, `line_spacing` Δf [Hz] | `critical_band_level(49.22, 137.3, 2.7)  # 64.97 dB` |
| `masking_index` | `function` | **Masking index av = −2−lg[1+(f/502)²·⁵] (Formula 13).**<br>• `frequency` f [Hz] | `masking_index(137.3)  # -2.02 dB` |
| `audibility_from_levels` | `function` | **ΔL = LT − LG − av (Formula 14).**<br>• `tone_level` LT, `critical_band_level` LG, `masking_index` av [dB] | `audibility_from_levels(67.96, 64.98, -2.02)  # 5.0 dB` |
| `energy_sum_level` | `function` | **Energy sum of lines with window correction (Formulae 7/8).**<br>• `line_levels` Li [dB]; a single line (K = 1) takes its level unchanged (Formula 7, no correction)<br>• `effective_bandwidth_factor` Δfe/Δf (Default: 1.5, Hanning; K > 1 only) | `energy_sum_level([80, 80])  # 81.25 dB` |
| `mean_narrowband_level` | `function` | **Masking-noise level LS from a critical-band spectrum (Formula 6, iterative Annex D).**<br>• `levels` Li [dB], `frequencies` [Hz]<br>• `tone_frequency` fT [Hz]<br>• `effective_bandwidth_factor` (Default: 1.5) | `mean_narrowband_level(levels, freqs, 137.3)  # 49.22 dB` |
| `tone_level` | `function` | **Tone level LT from the tonal lines about a peak (Formulae (7)/(8): single lines take (7), without the Hanning bandwidth correction).**<br>• `levels` Li [dB], `frequencies` [Hz]<br>• `tone_frequency` fT [Hz], `mean_narrowband_level` LS [dB]<br>• `effective_bandwidth_factor` (Default: 1.5) | `tone_level(levels, freqs, 137.3, ls)  # 67.96 dB` |
| `audibility_uncertainty` / `mean_audibility_uncertainty` | `function` | **Extended uncertainty U of the audibility (ISO/PAS 20065 Clause 5.4/6, 90 % bilateral).**<br>• per tone: `audibility_uncertainty(tone_line_levels, noise_line_levels, tone_frequency, line_spacing)`<br>• mean: U of the energy-averaged audibility over the per-spectrum (ΔL, U) pairs<br>Mandatory when fewer than 12 spectra are averaged (Clause 6) | `u = audibility_uncertainty(lt_lines, ls_lines, 137.3, 2.7)`<br><br>• U [dB] |
| `analyze_spectrum` | `function` | **Detect & rate the audible tones of a spectrum (Clause 5.3.8 + distinctness 5.3.4), incl. Step 3 same-band FG combination (Formula 17).**<br>• `levels` Li [dB], `frequencies` [Hz]<br>• `line_spacing` Δf [Hz]<br>• `effective_bandwidth_factor` (Default: 1.5) | `res = analyze_spectrum(levels, freqs, 2.7)`<br><br>• `ToneAudibilityResult` of detected tones + FG entries (`group_sizes`) |
| `combined_tone_level` | `function` | **Multi-tone FG combined level (Formula 17).**<br>• `levels` Li [dB], `frequencies` [Hz]<br>• `tone_frequencies` [Hz], `mean_narrowband_levels` LS [dB]<br>• `effective_bandwidth_factor` (Default: 1.5) | `combined_tone_level(lv, f, [118.4,137.3,158.8], ls)  # 72.15 dB` |
| `two_tone_separation_frequency` | `function` | **Two-tone separation threshold fD = 21·10^(1.2·\|lg(fT/212)\|^1.8) Hz (Formula 19).**<br>• `tone_frequency` fT [Hz] (more prominent tone) | `two_tone_separation_frequency(212.0)  # 21.0 Hz` |
| `resolve_tones_separately` | `function` | **Rate two tones <1000 Hz separately vs combined (Formulae 18/19).**<br>• `tone1_frequency`, `tone2_frequency` [Hz]<br>• `audibility1`, `audibility2` ΔL [dB] | `resolve_tones_separately(200., 260., 3., 2.)  # True` |
| `mean_audibility` | `function` | **Energy-mean mean audibility over spectra (Formula 20).**<br>• `decisive_audibilities` ΔLj [dB]; no-tone spectra use −10 dB | `mean_audibility([9.18, 6.04, 7.46])  # dB` |
| `ToneAudibilityResult` | `dataclass` | **Tonal audibility of a spectrum's tones (ISO/PAS 20065).**<br>• `audibilities` ΔL, `critical_band_levels` LG, `masking_indices` av [dB]<br>• `critical_bandwidths` Δfc, `lower_corners`/`upper_corners` [Hz]<br>• `audible`: ΔL > 0 mask<br>• `extended_uncertainties`: Clause 5.4 U per tone [dB]<br>• `group_sizes`: 1 = single tone, N ≥ 2 = Step 3 FG entry (None from `assess_tones`)<br>• `decisive_audibility` / `decisive_frequency`<br>• `.plot()`: per-tone ΔL vs frequency | `res.decisive_audibility, res.audible` |
| `HANNING_BANDWIDTH_FACTOR` | `float` | **Hanning effective-bandwidth factor Δfe/Δf (ISO/PAS 20065 Annex A).**<br>1.5 | `HANNING_BANDWIDTH_FACTOR  # 1.5` |
| `NO_TONE_AUDIBILITY` | `float` | **Audibility reported when no tone is found [dB] (ISO/PAS 20065 Formula 21).**<br>-10.0 | `NO_TONE_AUDIBILITY  # -10.0` |
| `sti_from_impulse_response` | `function` | **Full STI, indirect method (IEC 60268-16 Ed. 5).**<br>• `ir`: Impulse response (1D)<br>• `fs`: Sample rate [Hz] (>= 22500)<br>• `snr`: SNR [dB], scalar or 7 bands (Default: None)<br>• `level`: 7 speech band levels [dB SPL]; enables masking + reception threshold (Default: None)<br>• `ambient`: 7 noise band levels [dB SPL]; requires `level` | `res = sti_from_impulse_response(ir, fs, snr=25.0)`<br><br>• `STIResult` with `mtf` (7×14) |
| `stipa` | `function` | **STIPA direct method (Annex B).**<br>• `x`: Recorded STIPA signal (1D), 15-25 s<br>• `fs`: Sample rate [Hz] (>= 22500)<br>• `reference`: Measured source signal instead of the nominal m = 0.55 (Default: None)<br>• `level` / `ambient`: as `sti_from_impulse_response`<br>• warns (`UserWarning`) if the recording is < 15 s (STI biased low) | `res = stipa(recording, fs)`<br><br>• `STIResult` with `mtf` (7×2) |
| `stipa_signal` | `function` | **STIPA test-signal generator (A.4/A.6.1).**<br>• `fs`: Sample rate [Hz] (>= 22500)<br>• `seconds`: Duration [s] (Default: 18.0)<br>• `level_db`: RMS level [dB SPL] (Default: None → RMS 0.1)<br>• `seed`: Pink-noise seed (Default: None) | `sig = stipa_signal(48000, seconds=18.0)`<br><br>• 1D test signal, Ed. 5 male spectrum |
| `STIResult` | `dataclass` | **STI result.**<br>• `sti`: 0 to 1<br>• `mti`: band indices (7,)<br>• `mtf`: corrected m values (7×14 or 7×2)<br>• `band_levels`: levels used or None<br>• `rating`: Annex F letter 'A+'…'U' | `res = stipa(x, fs)`<br>`print(res.sti, res.rating)` |
| `sound_intensity` | `function` | **p-p sound intensity (IEC 61043).**<br>• `p1`, `p2`: Microphone signals [Pa], equal length<br>• `fs`: Sample rate [Hz]<br>• `spacing`: Microphone separation Δr [m]<br>• `rho`: Air density (Default: 1.204)<br>• `c`: Speed of sound (Default: 343.0)<br>• `fraction`: None, 1 or 3 (Default: None)<br>• `limits`: [f_min, f_max] (Default: [12, 20000])<br>• `bias_correct`: undo finite-difference under-read of band/broadband totals near `max_valid_frequency` (Default: False) | `res = sound_intensity(p1, p2, fs, spacing=0.012, fraction=3)`<br><br>• `IntensityResult` |
| `IntensityResult` | `dataclass` | **Intensity result.**<br>• Per band (with `fraction`): `frequency`, `intensity` [W/m²], `intensity_level`, `pressure_level`, `pressure_intensity_index`, `direction` (±1), `bias_correction`<br>• Broadband: `total_*` counterparts<br>• `max_valid_frequency`: 0.1·c/Δr | `res.total_intensity_level`<br>`res.total_direction` |
| `field_indicators` | `function` | **ISO 9614-1 Annex A field indicators.**<br>• `pressure_levels`: Lpi per position [dB]<br>• `normal_intensity`: Signed Ini per position [W/m²] | `fi = field_indicators(lp, i_n)`<br><br>• `FieldIndicators(f2, f3, f4)` |
| `FieldIndicators` | `dataclass` | **ISO 9614-1 Annex A indicators.**<br>• `f2`: surface pressure-intensity indicator (Eq. A.3)<br>• `f3`: negative partial power indicator (Eq. A.6)<br>• `f4`: field non-uniformity indicator (Eq. A.8)<br>• f3 − f2 > 0 reveals negative partial power | `fi.f2, fi.f3, fi.f4`<br><br>• Criteria: Ld > F2, N > C·F4² |
| `dynamic_capability_index` | `function` | **Dynamic capability Ld (ISO 9614-1 §3.12).**<br>• `pressure_residual_intensity_index`: δpI0 [dB]<br>• `bias_error_factor`: K [dB] (Default: 10.0) | `ld = dynamic_capability_index(18.0)`<br><br>• Adequate when `Ld > F2` (criterion 1) |
| `CalibrationWarning` | `warning class` | **Unreliable calibration recording.**<br>Emitted by `sensitivity` (with `fs` given and `validate=True`) when the tone is unstable (IEC 60942 limit) or too short | `warnings.simplefilter("error", CalibrationWarning)` |
| `lden` | `function` | **Day-evening-night level (ISO 1996-1 §3.6.4).**<br>• `lday`/`levening`/`lnight`: LAeq per period [dB]<br>• `hours`: Period durations (Default: (12, 4, 8)) | `l = lden(63.2, 58.1, 51.4)` |
| `ldn` | `function` | **Day-night level (ISO 1996-1 §3.6.5).**<br>• `lday`/`lnight`: LAeq per period [dB]<br>• `hours`: Default (15, 9) | `l = ldn(63.2, 51.4)` |
| `composite_rating_level` | `function` | **Whole-day composite rating (ISO 1996-1 §6.5).**<br>• `periods`: list of (level_db, hours, adjustment_db) summing 24 h | `r = composite_rating_level([(63, 12, 0), (58, 4, 5), (51, 8, 10)])` |
| `assess_tonal_audibility` | `function` | **Tonal audibility & adjustment (ISO 1996-2 Annex C).**<br>• `tone_level` Lpt, `masking_noise_level` Lpn [dB]<br>• `centre_frequency` fc [Hz] | `res = assess_tonal_audibility(54.1, 45.2, 430.0)`<br><br>• `TonalAssessmentResult` (`.audibility`, `.adjustment`, `.plot()`) |
| `tonal_audibility` | `function` | **ΔLta = Lpt−Lpn+2+lg[1+(fc/502)²·⁵] (Formula C.3).**<br>• `tone_level`, `masking_noise_level` [dB], `centre_frequency` [Hz] | `d = tonal_audibility(54.1, 45.2, 430.0)  # 11.1 dB` |
| `tonal_adjustment` | `function` | **Kt(ΔLta) piecewise (Formulae C.4-C.6).**<br>• `audibility` ΔLta [dB] | `tonal_adjustment(7.0)  # 3.0 dB` |
| `tonal_adjustment_from_mean_audibility` | `function` | **Kt from mean audibility ΔL (ISO 1996-2 Table J.1).**<br>• `mean_audibility` [dB], `coarse` (Default: False) | `tonal_adjustment_from_mean_audibility(5.0)  # 3` |
| `critical_bandwidth` | `function` | **Critical bandwidth (ISO 1996-2 Table C.1).**<br>• `centre_frequency` [Hz] (100 Hz ≤ 500 Hz, else 20 %·fc) | `critical_bandwidth(4000.0)  # 800 Hz` |
| `tonal_seeking_survey` | `function` | **One-third-octave tonal screen (ISO 1996-2 Annex K).**<br>• `levels` [dB], `frequencies` [Hz] (15/8/5 dB neighbour rule) | `flags = tonal_seeking_survey(levels, freqs)` |
| `residual_sound_correction` | `function` | **Residual-noise correction L = 10 lg(10^(L'/10)−10^(Lres/10)) (Formula 16).**<br>• `measured_level` L', `residual_level` Lres [dB] | `res = residual_sound_correction(58.0, 50.0)`<br><br>• `ResidualCorrectionResult` (`.corrected_level`, `.reportable_upper_bound`, `.reliable`) |
| `gaussian_residual_level` | `function` | **Residual Leq from percentiles (ISO 1996-2 Annex I).**<br>• `l50` [dB]; exactly one of `l90` / `l95` (must not exceed `l50`) | `gaussian_residual_level(50.0, l90=40.0)` |
| `combined_standard_uncertainty` | `function` | **u = √(Σ(cj·uj)²) (ISO 1996-2 Formula 2).**<br>• `contributions`: cj·uj products or (uj, cj) pairs | `u = combined_standard_uncertainty([0.59, 0.3, 2.0, 0.40, 0.38])  # 2.18` |
| `environmental_expanded_uncertainty` | `function` | **U = k·u (ISO 1996-2 §4).**<br>• `standard_uncertainty` [dB], `confidence` 0.95 (k=2) / 0.80 (k=1.3) | `environmental_expanded_uncertainty(2.18)  # 4.36` |
| `residual_correction_uncertainty` | `function` | **Uncertainty of the residual-corrected level (Formulae F.7-F.9).**<br>• `measured_level`, `residual_level`, `measured_uncertainty`, `residual_uncertainty` [dB] | `residual_correction_uncertainty(58, 50, 0.5, 2.0)` |
| `uncertainty_from_repeated_measurements` | `function` | **Energy mean & uncertainty from repeats: primary Formulae (17)+(19) route, Formula (20) approximation alongside.**<br>• `levels` [dB] (≥ 2); warns when they spread > 3 dB | `res = uncertainty_from_repeated_measurements(levels)`<br><br>• `RepeatedMeasurementResult` |
| `TonalAssessmentResult` | `dataclass` | **Tonal assessment (ISO 1996-2).**<br>• `audibility` ΔLta, `adjustment` Kt [dB]<br>• `centre_frequency`, `critical_bandwidth`; `.plot()` | `res.audibility, res.adjustment` |
| `ResidualCorrectionResult` | `dataclass` | **Residual-corrected level (ISO 1996-2).**<br>• `corrected_level` [dB], `reportable_upper_bound` = measured L' [dB], `margin` [dB], `reliable` (> 3 dB; when False report the upper bound, not the correction) | `res.corrected_level, res.reliable` |
| `RepeatedMeasurementResult` | `dataclass` | **Repeat-measurement mean & uncertainty (ISO 1996-2).**<br>• `mean_level`, `standard_uncertainty` (Formulae (17)+(19)), `approximate_uncertainty` (Formula (20)) [dB], `n` | `res.mean_level, res.standard_uncertainty` |
| `linkwitz_riley` | `function` | **Audio crossover.**<br>• `x`: Signal array<br>• `fs`: Sample rate [Hz]<br>• `freq`: Crossover frequency [Hz]<br>• `order`: Any even number (Default: 4) | `lo, hi = linkwitz_riley(x, fs, freq=1000, order=4)`<br><br>• `lo`: Low-pass filtered signal<br>• `hi`: High-pass filtered signal |
| `parametric_eq` | `function` | **Parametric EQ, one shot (RBJ Audio EQ Cookbook).**<br>• `x`: Signal array (1D or 2D `[channels, samples]`)<br>• `fs`: Sample rate [Hz]<br>• `sections`: an `EQSection` or a sequence of them | `y = parametric_eq(x, fs, EQSection("peaking", 1000, gain_db=6.0))` |
| `ParametricEQ` | `class` | **Reusable parametric-EQ cascade (RBJ Audio EQ Cookbook).**<br>• `fs`: Sample rate [Hz]<br>• `sections`: `EQSection` or sequence of them (cascade order)<br>• `stateful`: Block processing (Default: False)<br>• `steady_ic`: Steady-state initial conditions (Default: False)<br>• `.sos`: the designed cascade, `.filter(x)`, `.response()` | `eq = ParametricEQ(fs, sections)`<br>`y = eq.filter(x)`<br>`res = eq.response()` |
| `EQSection` | `dataclass` | **One cookbook biquad specification.**<br>• `filter_type`: 'peaking', 'lowshelf', 'highshelf', 'lowpass', 'highpass', 'bandpass', 'bandpass_skirt', 'notch', 'allpass'<br>• `f0`: Centre/corner frequency [Hz]<br>• `gain_db`: Gain (peaking/shelves) [dB]<br>• One of `q` (Default: 1/√2), `bw` [octaves] or `slope` (shelves) | `EQSection("peaking", 1000.0, gain_db=-6.0, bw=1.0)` |
| `EQResponseResult` | `dataclass` | **Parametric-EQ cascade response.**<br>• `frequencies` [Hz], `magnitude_db` [dB], `phase_rad` [rad]<br>• `section_magnitude_db`: per-section magnitudes [dB]<br>• `sos`: the cascade, `fs`, `sections`; `.plot()` magnitude + phase | `res = eq.response()`<br>`res.plot()` |
| `sensitivity` | `function`| **SPL Calibration.**<br>• `ref_signal`: Calibration signal<br>• `target_spl`: Level of calibrator (Default: 94.0)<br>• `ref_pressure`: Reference pressure (Default: 20e-6)<br>• `narrowband`: coherent Goertzel tone estimate that rejects broadband hum/noise (needs `fs`; Default: False) | `s = sensitivity(ref_signal, target_spl=94.0)`<br><br>• `s`: Float (multiplier for pressure) |
| `verify_filter_class` | `function` | **IEC 61260 class check.**<br>• `bank`: an `OctaveFilterBank`<br>• `num_points`: frequency grid points per band (Default: 32768)<br>• `edition`: `'2014'` (classes 1/2, default) or `'1995'` (IEC 61260:1995 / ANSI S1.11-2004, adds class 0) | `result = verify_filter_class(bank, edition="1995")`<br><br>• `result["overall_class"]`: 1/2 or None (`'2014'`); 0/1/2 or None (`'1995'`)<br>• `result["bands"]`: per-band class and `margin_class<c>_db` [dB] |
| `filter_class_compliance` | `function` | **IEC 61260 class check as a report result.**<br>Same verification as `verify_filter_class`, returned as a `FilterComplianceResult` with `.plot()` and `.report()`.<br>• `bank`: an `OctaveFilterBank`<br>• `num_points`: frequency grid points per band (Default: 32768)<br>• `edition`: `'2014'` (default) or `'1995'` (adds class 0) | `result = filter_class_compliance(bank, edition="1995")`<br>`result.report("iec61260.pdf", metadata=ReportMetadata(required_class=0))` |
| `FilterComplianceResult` | `dataclass` | **IEC 61260 filter class-compliance result.**<br>• `overall_class`: strictest class every band meets: 1/2 (`'2014'`), 0/1/2 (`'1995'`), or `None`<br>• `bands`: per-band verdict dictionaries<br>• `range_limited`: stop-band mask beyond the processing Nyquist not exercised<br>• `.available_classes()` / `.reference_class()`: the edition's classes and the fiche's reference class<br>• `.plot()`: measured relative attenuation over the class corridor<br>• `.report(path, *, metadata=None, required_class via metadata)`: one-page PDF fiche | `filter_class_compliance(bank).report("iec61260.pdf")` |
| `class_limits` | `function` | **IEC 61260 Table 1 acceptance limits.**<br>• `fraction`: bandwidth designator denominator b (1, 3, ...)<br>• `filter_class`: 1 or 2 (`'2014'`); 0, 1 or 2 (`'1995'`)<br>• `omega`: normalized frequencies f/fm<br>• `edition`: `'2014'` (default) or `'1995'` | `lo, hi = class_limits(3, 0, omega, edition="1995")`<br><br>• `lo` / `hi`: min/max relative attenuation [dB] (`hi` is `inf` outside the pass-band) |
| `verify_weighting_class` | `function` | **Weighting tolerance check** against IEC 61672-1:2013 Table 3 (A/C/Z), ANSI S1.4-1983 Tables IV/V (B, Types as classes) or IEC 61012:1990 Table 1 (AU), at the exact base-10 frequencies, plus the 5.5.7 between-nominals sweep.<br>• `wf`: a `WeightingFilter` (A, B, C, AU or Z)<br>• `sweep_points` (Default: 4096) | `result = verify_weighting_class(wf)`<br><br>• `result["overall_class"]`: 1, 2 or None<br>• `result["range_limited"]`: finite-lower-limit rows beyond Nyquist<br>• `result["bands"]`: per-frequency class, deviation and margins [dB]<br>• `result["between_nominals"]`: 5.5.7 sweep margins |
| `weighting_class_limits` | `function` | **IEC 61672-1:2013 Table 3 acceptance limits.**<br>• `weighting_class`: 1 or 2 | `f, lo, hi = weighting_class_limits(1)`<br><br>• `f`: 34 nominal frequencies [Hz]<br>• `lo` / `hi`: lower/upper deviation limits [dB] (`lo` is `-inf` where one-sided) |
| `nominal_frequencies` | `function` | **ANSI Frequency generator.**<br>• `fraction`: 1, 3, etc. (Required)<br>• `limits`: [f_min, f_max] (Default: [12, 20000]) | `f_cen, f_low, f_high, labels = nominal_frequencies(fraction=3)`<br><br>• `f_cen`: List of center frequencies [Hz]<br>• `f_low`: List of lower edges [Hz]<br>• `f_high`: List of upper edges [Hz]<br>• `labels`: IEC nominal frequency labels |
| `normalized_frequencies` | `function` | **Standard IEC Frequencies.**<br>• `fraction`: 1 or 3 | `freqs = normalized_frequencies(fraction=3)`<br><br>• `freqs`: List of standard center frequencies [Hz] |
| `sweep_signal` | `function` | **ESS excitation (ISO 18233 Annex B).**<br>• `fs`: Sample rate [Hz]<br>• `f1`: Start frequency [Hz]<br>• `f2`: Stop frequency [Hz] (≤ fs/2)<br>• `seconds`: Duration [s]<br>• `amplitude`: Peak (Default: 1.0)<br>• `fade`: Half-Hann fraction (Default: 0.01) | `s = sweep_signal(48000, 20, 20000, 3.0)`<br><br>• 1D exponential sine sweep |
| `inverse_filter` | `function` | **Farina inverse filter for an ESS.**<br>• Same parameters as `sweep_signal` | `inv = inverse_filter(48000, 20, 20000, 3.0)`<br><br>• Time-reversed, +6 dB/oct compensated sweep |
| `impulse_response` | `function` | **Sweep deconvolution (ISO 18233 B.5).**<br>• `recorded`: Recorded response (1D)<br>• `reference`: Emitted sweep (1D)<br>• `fs`: Sample rate [Hz]<br>• `method`: 'spectral' (Default) or 'farina'<br>• `f_range`: (f1, f2) for 'farina'<br>• `regularization`: Tikhonov term (Default: 1e-6)<br>• `length`: causal samples (Default: len(recorded))<br>• `return_full`: keep distortion tail (Default: False) | `ir = impulse_response(rec, sweep, fs)`<br><br>• `ImpulseResponseResult` (array-like; `.plot()`) |
| `mls_signal` | `function` | **Maximum-length sequence (ISO 18233 Annex A).**<br>• `order`: Register length N, 2-20 | `mls = mls_signal(16)`<br><br>• Bipolar sequence, length 2**N − 1 |
| `mls_impulse_response` | `function` | **IR from a periodic MLS.**<br>• `recorded`: Response spanning whole MLS periods (1D)<br>• `mls`: Excitation sequence<br>• `length`: IR samples (Default: 2**N − 1)<br>• `fs`: optional sample rate for the plot time axis | `ir = mls_impulse_response(rec, mls)`<br><br>• `ImpulseResponseResult` (array-like; `.plot()`) |
| `ImpulseResponseResult` | `dataclass` | **Recovered impulse response with metadata.**<br>• `ir`: IR samples<br>• `fs` [Hz] or None (e.g. an MLS recovery without one)<br>• `method`: 'spectral'/'farina'/'mls'/'golay'<br>• array-like: `np.asarray(res)`, indexing, `len()` and `shape`/`dtype` forward to `ir`<br>• `.plot()` | `ir = impulse_response(rec, sweep, fs)`<br>`room_parameters(ir, fs)  # drop-in array` |
| `golay_pair` | `function` | **Complementary Golay pair (Havelock Pt. I Ch. 6).**<br>• `order`: recursion steps n, 1-22; each code has 2**n samples<br>Periodic autocorrelations sum to an exact 2L·delta | `a, b = golay_pair(14)`<br><br>• Two bipolar codes, length 2**n |
| `golay_impulse_response` | `function` | **IR from a periodic Golay-pair excitation.**<br>• `recorded_a` / `recorded_b`: steady-state responses to each code (whole periods)<br>• `pair`: the `(a, b)` codes<br>• `length`: IR samples (Default: 2**n)<br>• `fs`: optional sample rate for the plot time axis<br>Noiseless LTI recovery is exact (machine precision) | `ir = golay_impulse_response(rec_a, rec_b, pair)`<br><br>• `ImpulseResponseResult` (array-like; `.plot()`) |
| `shaped_sweep_signal` | `function` | **Sweep with an arbitrary target spectrum (Mueller & Massarani Secs. 4.2-4.3).**<br>• `fs`, `f1`, `f2`, `seconds`<br>• `target`: 'pink' (Default), 'white' or `(frequencies_hz, magnitude_db)`<br>• `amplitude` (Default: 1.0), `start_delay` (Default: 0.05·seconds), `fade` (Default: 0.01)<br>Group delay grows with the target power: near-constant envelope | `sweep = shaped_sweep_signal(fs, 50, 5000, 2, target="pink")`<br><br>• `ShapedSweepResult` (array-like; `.plot()`) |
| `ShapedSweepResult` | `dataclass` | **Synthesized shaped sweep.**<br>• `signal`, `fs`, `frequencies`, `magnitude` (imposed, peak 1), `group_delay` [s], `f_range`, `crest_factor_db`<br>• array-like: usable directly as the deconvolution reference<br>• `.plot()`: waveform + Welch spectrum vs target | `ir = impulse_response(rec, np.asarray(sweep), fs)` |
| `ImpulseResponseWarning` | `warning class` | **Suspect recovered impulse response.**<br>Emitted e.g. for MLS aliasing in the recovery | `warnings.simplefilter('error', ImpulseResponseWarning)` |
| `plot_excitation` | `function` | **Plot an excitation signal.**<br>• `signal`: sweep or MLS samples (1D)<br>• `fs`: Sample rate [Hz]<br>• `kind`: `'sweep'` (default) or `'mls'` | `plot_excitation(sweep, fs, kind="sweep")`<br><br>• waveform + spectrogram / spectrum axes |
| `decay_curve` | `function` | **Schroeder decay curve (ISO 3382-1 5.3.3).**<br>• `ir`: Impulse response (1D)<br>• `fs`: Sample rate [Hz]<br>• `band`: Band centre [Hz] (Default: None → broadband)<br>• `fraction`: 1 or 3 (Default: 1)<br>• `zero_phase`: forward-backward band filtering, halves the 125 Hz short-T bias (Default: False) | `dc = decay_curve(ir, fs)` → `DecayCurve`<br>`time, level = dc` still unpacks (`time` [s], `level` [dB], 0 dB at t=0)<br>`dc.plot()` draws the decay + EDT/T20/T30 fits |
| `DecayCurve` | `dataclass` | **Schroeder decay curve (ISO 3382-1 5.3.3).**<br>• `time`: from the direct sound [s]<br>• `level`: decay [dB], 0 dB at t = 0, up to the noise truncation point<br>• `band`: centre [Hz] or None (broadband)<br>• iterable: unpacks as `time, level`<br>• `.plot()`: decay + EDT/T20/T30 fits | `dc = decay_curve(ir, fs)`<br>`dc.time, dc.level, dc.band` |
| `room_parameters` | `function` | **Room acoustics (ISO 3382-1/2).**<br>• `ir`: Impulse response (1D)<br>• `fs`: Sample rate [Hz]<br>• `limits`: (f_min, f_max) or None (Default: (125, 4000))<br>• `fraction`: 1 or 3 (Default: 1)<br>• `zero_phase`: forward-backward octave filtering, halves the 125 Hz short-T T30 bias (Default: False) | `res = room_parameters(ir, fs)`<br><br>• `RoomAcousticsResult` per band |
| `RoomAcousticsResult` | `dataclass` | **Per-band room parameters.**<br>• `frequency` [Hz]<br>• `edt`, `t20`, `t30` [s]<br>• `c50`, `c80` [dB], `d50`, `ts` [s]<br>• `dynamic_range` [dB]<br>• `edt_valid`/`t20_valid`/`t30_valid`<br>• `curvature` [%] | `res.t30, res.c80, res.t30_valid`<br><br>• Arrays with one entry per band |
| `open_plan_metrics` | `function` | **Open-plan office metrics (ISO 3382-3).**<br>• `positions_m`: Source distances [m] (≥ 4)<br>• `spl_a_speech`: A-weighted speech level per position [dB]<br>• `sti_values`: STI per position | `m = open_plan_metrics(r, lp, sti)`<br><br>• `OpenPlanResult` |
| `OpenPlanResult` | `dataclass` | **Open-plan single numbers.**<br>• `d2s`: Spatial decay rate [dB/doubling]<br>• `lp_as_4m`: Speech level at 4 m [dB]<br>• `rd`: Distraction distance [m]<br>• `rp`: Privacy distance [m] | `m.d2s, m.rd, m.rp`<br><br>• `nan` when undefined |
| `image_source_rir` | `function` | **Image-source room impulse response (Kuttruff 4.1 / Vorländer 11).**<br>• `dimensions`: `(Lx, Ly, Lz)` [m]<br>• `source`/`receiver`: `(x, y, z)` [m]<br>• `absorption`: scalar / (6,) per-wall / per-band / (6, n) [0, 1]<br>• `fs` [Hz], `max_order` (Default: 20)<br>• `air_attenuation`: intensity `m` [1/m]<br>• `frequencies` [Hz] for a per-band RIR | `res = image_source_rir((7,5,3), (2,1.6,1.5), (5.2,3.4,1.7), 0.12, fs=48000)`<br><br>• `ImageSourceResult` |
| `ImageSourceResult` | `dataclass` | **Synthetic RIR by image sources.**<br>• `ir`: sampled RIR (1D or per-band)<br>• exact reflection table `times`/`distances`/`orders`/`amplitudes`/`image_positions`<br>• `direct_time` [s]<br>• `.plot()`: reflectogram | `res.ir, res.times, res.orders`<br>`res.plot()` |
| `audible_image_count` / `reflection_density` | `function` | **Shoebox image count (Kuttruff Eq. 9.23) and reflection density (Eq. 4.6).**<br>• `audible_image_count(order)`: `(2/3)(2i³+3i²+4i)`<br>• `reflection_density(t, volume, c=343)`: `4πc³t²/V` | `audible_image_count(10)  # 1560`<br>`reflection_density(0.1, 120.0)` |
| `steady_state_field` | `function` | **Steady-state room field (Bies 6.4).**<br>• `sound_power_level` `Lw` [dB]<br>• `surface_area` `S` [m²], `mean_absorption` `ᾱ` (0, 1)<br>• `distances` [m] (Default: 0.1–10 rc)<br>• `directivity` `Q` (Default: 1)<br>• `characteristic_impedance` [Pa·s/m] for the ρc term | `f = steady_state_field(90.0, 100.0, 0.2)`<br><br>• `SteadyFieldResult` |
| `SteadyFieldResult` | `dataclass` | **SPL vs distance, direct/reverberant/total.**<br>• `distances` [m]<br>• `direct`/`reverberant`/`total` [dB]<br>• `critical_distance` [m], `room_constant` [m²]<br>• `.plot()` | `f.total, f.critical_distance`<br>`f.plot()` |
| `room_constant` / `critical_distance` / `schroeder_frequency` / `steady_state_spl` | `function` | **Steady-field building blocks (Bies 6.43/6.44, Kuttruff 3.44/5.44).**<br>• `room_constant(S, ᾱ)` = `Sᾱ/(1-ᾱ)` [m²]<br>• `critical_distance(R, directivity=1)` = `√(QR/16π)` [m]<br>• `schroeder_frequency(T, V)` = `2000√(T/V)` [Hz]<br>• `steady_state_spl(Lw, r, R, directivity=1)` [dB] | `R = room_constant(100, 0.2)`<br>`critical_distance(R)`<br>`schroeder_frequency(1.0, 200.0)` |
| `airborne_insulation` | `function` | **Field airborne insulation (ISO 16283-1).**<br>• `l1`/`l2`: Source/receiving levels [dB], 1D or (positions, bands)<br>• `t2`: Receiving-room T per band [s]<br>• `area`: Partition S [m²] (for R')<br>• `volume`: Receiving V [m³] (for R')<br>• `t0`: Reference T0 [s] (Default: 0.5) | `ins = airborne_insulation(l1, l2, t2, area=10, volume=50)`<br><br>• `AirborneInsulationResult` |
| `weighted_rating` | `function` | **Single-number rating + C/Ctr (ISO 717-1).**<br>• `values_by_band`: 16 thirds or 5 octaves [dB]<br>• `bands`: 'third-octave', 'octave' or None | `w = weighted_rating(R)`<br><br>• `WeightedRatingResult` (Rw, C, Ctr) |
| `weighted_rating_extended` | `function` | **Rating + enlarged-range terms (ISO 717-1 Annex B).**<br>• `values_by_band` [dB] with `frequencies` [Hz] (None = the 16 core bands)<br>• `one_decimal`: 0.1 dB shift + one-decimal reductions for uncertainty statements (Default: False) | `ext = weighted_rating_extended(r, freqs)`<br><br>• `ExtendedWeightedRatingResult` (rating, c, ctr, c_50_3150 … ctr_100_5000) |
| `ExtendedWeightedRatingResult` | `dataclass` | **Weighted rating with the enlarged-range adaptation terms (ISO 717-1 Annex B).**<br>• `rating` + `c`, `ctr`<br>• `c_50_3150`, `c_50_5000`, `c_100_5000` and the `ctr_` twins<br>• `core`: the base `WeightedRatingResult` | `res = weighted_rating_extended(freqs, r)` |
| `energy_average_level` | `function` | **Energy-average level (ISO 16283-1 (9)).**<br>• `levels`: Levels [dB] to average<br>• `axis`: Averaging axis (Default: -1) | `L = energy_average_level([60, 66])`<br><br>• `10 lg( mean(10^(Li/10)) )` |
| `AirborneInsulationResult` | `dataclass` | **Field insulation per band.**<br>• `d`: Level difference D [dB]<br>• `dnt`: Standardized DnT [dB]<br>• `r_prime`: Apparent R' [dB] or None<br>• `l1`, `l2`, `t2`, `t0`: retained measurement chain (or None)<br>• `.report(path, quantity='dnt'/'r_prime', ...)`: ISO 16283-1 field test report (Annex B form) with the ISO 717-1 rating → PDF | `res.report("DnTw.pdf", metadata=ReportMetadata(requirement=50.0))` |
| `WeightedRatingResult` | `dataclass` | **Weighted rating result.**<br>• `rating`: Rw/DnT,w … [dB], int<br>• `c`: Spectrum term C, int<br>• `ctr`: Spectrum term Ctr, int<br>• `unfavourable_sum`: [dB]<br>• `band_centers`: Measured-curve centres [Hz] or None<br>• `measured`: Measured band values [dB] or None<br>• `shifted_reference`: Shifted Table 3 reference [dB] or None | `w.rating, w.c, w.ctr` |
| `impact_insulation` | `function` | **Field impact insulation (ISO 16283-2).**<br>• `li`: Impact SPL [dB], 1D or (positions, bands)<br>• `t2`: Receiving-room T per band [s]<br>• `volume`: Receiving V [m³] (for L'n)<br>• `t0`: Reference T0 [s] (Default: 0.5) | `imp = impact_insulation(li, t2, volume=50)`<br><br>• `ImpactInsulationResult` (`l_n_t`, `l_n`) |
| `weighted_impact_rating` | `function` | **Single-number impact rating + CI (ISO 717-2).**<br>• `values_by_band`: 16 thirds (100-3150 Hz) or 5 octaves (125-2000 Hz) [dB]<br>• `bands`: 'third-octave', 'octave' or None | `r = weighted_impact_rating(imp.l_n_t)`<br><br>• `ImpactRatingResult` (Ln,w, CI); octave rating carries the -5 dB rule |
| `weighted_impact_rating_extended` | `function` | **Impact rating + CI,50-2500 (ISO 717-2:2020 A.2.1 NOTE).**<br>• `values_by_band` [dB] with `frequencies` [Hz] (None = the 16 core bands)<br>• `one_decimal`: 0.1 dB shift (reproduces Ln,r,0,w = 77.6, CI,r,0 = -10.3 of A.2.2) (Default: False) | `ext = weighted_impact_rating_extended(ln, freqs)`<br><br>• `ExtendedImpactRatingResult` (rating, ci, ci_50_2500) |
| `ExtendedImpactRatingResult` | `dataclass` | **Weighted impact rating with CI,50-2500 (ISO 717-2:2020 A.2.1).**<br>• `rating`, `ci`, `ci_50_2500`<br>• `core`: the base `ImpactRatingResult` | `res = weighted_impact_rating_extended(freqs, ln)` |
| `weighted_impact_improvement` | `function` | **Weighted improvement ΔLw (ISO 717-2 §5).**<br>• `delta_l`: 16 one-third-octave ΔL (100-3150 Hz) [dB]<br>Applies the Table 4 reference floor: ΔLw = 78 − Ln,r,w | `weighted_impact_improvement(delta_l)`<br><br>• ΔLw [dB] (e.g. 19) |
| `impact_improvement_adaptation_term` | `function` | **Spectrum adaptation term CI,Δ (ISO 717-2:2020 Formula (A.4)).**<br>• `delta_l`: 16 one-third-octave ΔL (100-3150 Hz) [dB]<br>CI,Δ = CI,r,0 − CI,r (CI,r,0 = −11 dB); ISO 16251-1 clause 8 e) | `impact_improvement_adaptation_term(delta_l)`<br><br>• CI,Δ [dB], int |
| `impact_improvement` | `function` | **Floor-covering improvement ΔL (ISO 16251-1).**<br>• `bare`, `with_covering`: acceleration levels L0/L1 per band [dB]<br>• `frequencies` [Hz] (a clause 6.3 spectrum is rated on its 100-3150 Hz sub-range)<br>• `background`: optional Lb per band [dB] (Formula 2) | `res = impact_improvement(l0, l1, f)`<br><br>• `FloorCoveringImprovementResult` |
| `acceleration_level` | `function` | **Vibratory acceleration level La (ISO 16251-1 Formula 1).**<br>• `acceleration`: RMS a per band [m/s²]<br>• `reference`: a0 (Default: 1e-6) | `acceleration_level(1e-3)  # 60 dB` |
| `background_corrected_level` | `function` | **Background correction (ISO 16251-1 Formula 2).**<br>• `signal_and_background`, `background` [dB] | `L, limited = background_corrected_level(lp, lb)`<br><br>• (corrected [dB], limit-of-measurement mask) |
| `improvement_octave_bands` | `function` | **Thirds → octaves for ΔL (ISO 16251-1 Formula 5).**<br>• `improvement`, `frequencies` (whole octave triplets) | `f_oct, dl_oct = improvement_octave_bands(dl, f)` |
| `FloorCoveringImprovementResult` | `dataclass` | **Floor-covering improvement (ISO 16251-1).**<br>• `improvement` ΔL [dB], `delta_lw`, `ci_delta` (or None)<br>• `limited`: > ΔL mask; `.octave_bands()`, `.plot()` | `res.delta_lw, res.ci_delta` |
| `ImpactInsulationResult` | `dataclass` | **Impact insulation per band.**<br>• `l_n_t`: Standardized L'nT [dB]<br>• `l_n`: Normalized L'n [dB] or None<br>• `li`, `t2`, `t0`: retained measurement chain (or None)<br>• `.report(path, quantity='l_n_t'/'l_n', ...)`: ISO 16283-2 field test report (Annex C form) with the ISO 717-2 rating → PDF | `imp.report("LnTw.pdf", metadata=ReportMetadata(requirement=58.0))` |
| `ImpactRatingResult` | `dataclass` | **Weighted impact rating.**<br>• `rating`: Ln,w/L'n,w/L'nT,w [dB], int<br>• `ci`: Spectrum term CI, int<br>• `unfavourable_sum`: [dB]<br>• `band_centers`: Measured-curve centres [Hz] or None<br>• `measured`: Measured impact levels [dB] or None<br>• `shifted_reference`: Shifted impact reference [dB] or None | `r.rating, r.ci` |
| `ReportMetadata` | `dataclass` | **Metadata for the accredited ISO 717 `.report()` PDF fiche.**<br>• All fields optional; only the supplied ones render<br>• `specimen`, `client`, `manufacturer`, `test_room`, `instrumentation`, `calibration`, `mounting`, `measurement_standard`, `test_date`<br>• numeric (finite, positive): `area`, `mass_per_area`, `source_volume`, `receiving_volume`, `temperature`, `relative_humidity`, `pressure`, `requirement`<br>• `laboratory`, `operator`, `report_id`, `notes` | `w.report("f.pdf", metadata=ReportMetadata(area=10.0, requirement=52.0))` |
| `facade_insulation` | `function` | **Field façade insulation (ISO 16283-3).**<br>• `l1_2m`/`l2`: Level 2 m in front / receiving levels [dB], 1D or (positions, bands)<br>• `t2`: Receiving-room T per band [s]<br>• `area`: Element S [m²], `volume`: Receiving V [m³], `surface_level`: L1,s [dB] (all three for R')<br>• `method`: 'loudspeaker' (−1.5 dB) / 'road_traffic' (−3 dB)<br>• `t0`: Reference T0 [s] (Default: 0.5)<br>• `frequencies` [Hz] | `fac = facade_insulation(l1_2m, l2, t2, volume=50, area=11.5, surface_level=ls)`<br><br>• `FacadeInsulationResult` |
| `FacadeInsulationResult` | `dataclass` | **Façade insulation per band.**<br>• `d_2m`: Level difference D2m [dB]<br>• `d_2m_nt`: Standardized D2m,nT [dB]<br>• `d_2m_n`: Normalized D2m,n [dB] or None<br>• `r_prime`: Apparent R'45°/R'tr,s [dB] or None<br>• `frequencies` [Hz] or None<br>• `.plot()` | `fac.d_2m_nt, fac.r_prime` |
| `lab_airborne_insulation` | `function` | **Laboratory airborne insulation (ISO 10140-2).**<br>• `l1`/`l2`: Source/receiving levels [dB], 1D or (positions, bands)<br>• `t2`: Receiving-room T per band [s]<br>• `area`: Free test-opening S [m²]<br>• `volume`: Receiving V [m³] | `lab = lab_airborne_insulation(l1, l2, t2, area=10, volume=50)`<br><br>• `LabAirborneInsulationResult` |
| `lab_impact_insulation` | `function` | **Laboratory impact insulation (ISO 10140-3).**<br>• `li`: Tapping-machine impact SPL [dB], 1D or (positions, bands)<br>• `t2`: Receiving-room T per band [s]<br>• `volume`: Receiving V [m³] | `imp = lab_impact_insulation(li, t2, volume=50)`<br><br>• `LabImpactInsulationResult` |
| `intensity_sound_reduction` | `function` | **Intensity sound reduction index RI (ISO 15186-1 Formula (7)).**<br>• `lp1`: Source-room level [dB], 1D or (positions, bands)<br>• `l_in`: Normal intensity level over the surface [dB]<br>• `measurement_area`: Surface Sm [m²], `area`: Specimen S [m²]<br>• `kc`: Adaptation term per band [dB] for RI,M (Default: None) | `res = intensity_sound_reduction(lp1, l_in, measurement_area=12, area=10)`<br><br>• `IntensityReductionResult` (`r_i`, `r_i_modified`, `rating`, `rating_modified`) |
| `adaptation_term_kc` | `function` | **Adaptation term Kc (ISO 15186-1 Annex B).**<br>• `freq`: Midband frequencies [Hz]<br>• `boundary_area`/`volume`: Room Sb2/V2 for Formula (B.1); omit both for the 10 lg(1+61,4/f) approximation (B.2) | `kc = adaptation_term_kc(freqs)`<br><br>• Kc per band [dB] |
| `intensity_element_normalized_difference` | `function` | **Intensity element normalized level difference DI,n,e (ISO 15186-1 Formula (8)).**<br>• `lp1`/`l_in`: Source level / surface intensity level [dB]<br>• `measurement_area`: Surface Sm [m²]<br>• `n`: Element units N (Default: 1) | `d = intensity_element_normalized_difference(lp1, l_in, measurement_area=10)`<br><br>• `IntensityElementNormalizedResult` (`d_i_n_e`, `rating`) |
| `surface_pressure_intensity_indicator` / `combine_subareas` | `function` | **Surface indicator FpI and subarea combination (ISO 15186-1 Formulas (10)-(12)).**<br>• `lp`/`l_in`: Surface pressure / intensity levels [dB]<br>• `combine_subareas(l_in, measurement_area)`: `(subareas, bands)` levels + per-subarea areas (negative area = reverse-flow subarea, Clause 6.4.6) | `fpi = surface_pressure_intensity_indicator(lp, l_in)`<br><br>• `FpI` array; `(LIn, Sm = Σ abs(Smi))` |
| `IntensityReductionResult` / `IntensityElementNormalizedResult` | `dataclass` | **ISO 15186-1 intensity insulation results.**<br>• `r_i` / `r_i_modified` per band with `rating` / `rating_modified`<br>• `d_i_n_e` per band with `rating` | `res = intensity_sound_reduction(...)` |
| `reverberation_index` / `estimate_reverberation_index` | `function` | **Survey reverberation index k (ISO 10052:2021 Clause 3.3 / Table 4).**<br>• `reverberation_index(t)`: k = 10 lg(T/0.5) per band<br>• `estimate_reverberation_index(volume, room)`: k from Table 4 (`room`: `kitchen`/`bathroom`/`furnished`/`a`-`h`/`a+e`...); `weighted=True` for the A/C column | `k = reverberation_index(t)`<br><br>• k per band [dB] |
| `survey_airborne_insulation` | `function` | **Survey airborne insulation (ISO 10052:2021 Clauses 3.2-3.6).**<br>• `l1`/`l2`: source/receiving levels [dB]<br>• `reverberation_index`: k per band [dB]<br>• `volume` (for Dn/R'), `area` (R', V/7.5 rule) | `res = survey_airborne_insulation(l1, l2, k, volume=50, area=12)`<br><br>• `SurveyAirborneResult` (`d`, `d_nt`, `d_n`, `r_prime`, `rating`, `r_prime_rating`) |
| `survey_impact_insulation` / `survey_facade_insulation` | `function` | **Survey impact & façade insulation (ISO 10052:2021 Clauses 3.7-3.9 / 3.13-3.15).**<br>• `li` (impact) or `l1_2m`/`l2` (façade), `reverberation_index`, `volume` | `imp = survey_impact_insulation(li, k, volume=50)`<br><br>• `SurveyImpactResult` / `SurveyFacadeResult` |
| `survey_service_equipment_level` | `function` | **Service-equipment noise LXY (ISO 10052:2021 Clauses 3.16-3.18).**<br>• `measurements`: three A/C-weighted positions [dB]<br>• `reverberation_index`: k (scalar or per band)<br>• `volume` (for LXY,n) | `se = survey_service_equipment_level([35, 30, 32], 3.0, volume=50)`<br><br>• `SurveyServiceEquipmentResult` (`l_xy`, `l_xy_nt`, `l_xy_n`) |
| `SurveyAirborneResult` / `SurveyImpactResult` / `SurveyFacadeResult` / `SurveyServiceEquipmentResult` | `dataclass` | **ISO 10052 survey-method results.**<br>• per-band quantities (`d`, `d_nt`, `d_n`, `r_prime`; `l_i`, `l_nt`, `l_n`; `d_2m`...; `l_xy`...)<br>• weighted `rating` where the method defines one | `res = survey_airborne_insulation(...)` |
| `background_correction` | `function` | **Background-noise correction (ISO 10140-4 §4.3).**<br>• `signal_and_background`: Combined Lsb per band [dB]<br>• `background`: Lb per band [dB]<br>• 6–15 dB margin corrected, ≤6 dB capped at 1.3 dB, ≥15 dB unchanged | `L = background_correction(lsb, lb)`<br><br>• Corrected levels [dB] (`LabInsulationWarning` at the limit of measurement) |
| `LabAirborneInsulationResult` | `dataclass` | **Laboratory airborne result.**<br>• `r`: Sound reduction index R [dB]<br>• `absorption`: A = 0.16 V/T [m²]<br>• `rating`: `WeightedRatingResult` or None<br>• `.plot()` (needs the rating)<br>• `.report(path, ...)`: ISO 10140-2 laboratory test report with the ISO 717-1 rating → PDF (also needs the rating; 16 one-third-octave or 5 octave bands) | `lab.r, lab.rating.rating` |
| `LabImpactInsulationResult` | `dataclass` | **Laboratory impact result.**<br>• `l_n`: Normalized impact level Ln [dB]<br>• `absorption`: A [m²]<br>• `rating`: `ImpactRatingResult` or None<br>• `.plot()` (needs the rating)<br>• `.report(path, ...)`: ISO 10140-3 laboratory test report with the ISO 717-2 rating → PDF (also needs the rating; 16 one-third-octave or 5 octave bands) | `imp.l_n, imp.rating.rating` |
| `LabInsulationWarning` | `warning class` | **Limit-of-measurement condition (ISO 10140-4).**<br>Emitted by `background_correction` when a band's signal-to-background margin is ≤ 6 dB (fixed 1.3 dB cap applied) | `warnings.simplefilter('error', LabInsulationWarning)` |
| `predicted_airborne_insulation` | `function` | **Predicted apparent airborne R'w (EN 12354-1 Formula 26).**<br>• `r_direct`: Separating-element Rs,w [dB]<br>• `flanking_paths`: sequence of `FlankingPath` (Default: ())<br>• `delta_r_direct`: Lining ΔRDd,w [dB] (Default: 0) | `res = predicted_airborne_insulation(r_direct=57, flanking_paths=paths)`<br><br>• `AirbornePredictionResult` |
| `predicted_impact_insulation` | `function` | **Predicted apparent impact L'n,w (EN 12354-2 Formula 21).**<br>• `ln_w_eq`: Bare-floor equivalent Ln,w,eq [dB]<br>• `delta_l_w`: Covering improvement ΔLw [dB] (Default: 0)<br>• `k_correction`: Flanking K [dB] (Default: 0) | `imp = predicted_impact_insulation(ln_w_eq=76.2, delta_l_w=33, k_correction=2)`<br><br>• `ImpactPredictionResult` |
| `junction_vibration_reduction` | `function` | **Vibration reduction index Kij (EN 12354-1 Annex E.3-E.9).**<br>• `junction_type`: 'rigid_cross'/'rigid_t'/'flexible_t'/'lightweight_facade'/'lightweight_double_homogeneous'/'lightweight_double_coupled'/'corner'/'thickness_change'<br>• `path`: 'through' (K13) / 'corner' (K12=K23) / 'double_leaf' (K24)<br>• `mass_ratio`: m'⊥,i/m'i<br>• `frequency` [Hz] (Default: 500), `f1` [Hz] (Default: 125) | `k = junction_vibration_reduction('rigid_cross', 'through', 1.61)`<br><br>• Kij [dB] |
| `junction_min_vibration_reduction` | `function` | **Minimum Kij,min (EN 12354-1 Formula 29).**<br>• `coupling_length`: lf [m]<br>• `s_i`, `s_j`: Element areas [m²] | `kmin = junction_min_vibration_reduction(4.5, 11.5, 11.5)`<br><br>• Kij,min [dB] |
| `flanking_path` | `function` | **One flanking path Rij,w (EN 12354-1 Formula 28a).**<br>• `label`, `kind`: 'Ff'/'Df'/'Fd'<br>• `r_source`/`r_receive`: element indices [dB]<br>• `k_ij` [dB], `separating_area` Ss [m²], `coupling_length` lf [m]<br>• `delta_r` [dB] (Default: 0), `kij_min` [dB] clamp (Default: None) | `p = flanking_path(label='f', kind='Ff', r_source=49, r_receive=49, k_ij=12.4, separating_area=11.5, coupling_length=4.5)`<br><br>• `FlankingPath` |
| `flanking_element` | `function` | **The three paths (Ff, Df, Fd) of one flanking element.**<br>• `label`, `r_flanking`, `r_separating` [dB]<br>• `k_ff`/`k_fd`/`k_df` [dB]<br>• `separating_area` Ss [m²], `coupling_length` lf [m]<br>• `delta_r_ff`/`delta_r_fd`/`delta_r_df` [dB] (Default: 0)<br>• `flanking_area` SF [m²] (Default: None): enables the automatic Kij,min clamp (Clause 4.4.2 / Formula 29) | `ff, df, fd = flanking_element(label='floor', r_flanking=49, r_separating=57, k_ff=12.4, k_fd=8.9, k_df=8.9, separating_area=11.5, coupling_length=4.5)` |
| `combine_linings` | `function` | **Combine two lining improvements (EN 12354-1 Formulas 30/31).**<br>• `delta_a`, `delta_b` [dB] (pass 0 for a single lining) | `dr = combine_linings(14.0, 14.0)`<br><br>• max(a,b) + min(a,b)/2 = 21.0 [dB] |
| `equivalent_impact_level` | `function` | **Bare-floor equivalent Ln,w,eq (EN 12354-2 Annex B).**<br>• `mass_per_area`: m' [kg/m²] | `lneq = equivalent_impact_level(322.0)`<br><br>• 164 − 35 lg(m') = 76.2 [dB] |
| `impact_flanking_correction` | `function` | **Flanking correction K (EN 12354-2 Table 1).**<br>• `separating_mass`, `flanking_mass` [kg/m²] (nearest tabulated) | `k = impact_flanking_correction(322.0, 145.0)`<br><br>• K = 2 [dB], int |
| `standardized_impact_level` | `function` | **Standardized L'nT,w (EN 12354-2 Formula 3, exact 0.032·V form).**<br>• `l_prime_n_w`: L'n,w [dB]<br>• `volume`: Receiving V [m³] (Annex E.3's V/30 is the standard's own rounding) | `lnt = standardized_impact_level(45.2, 50.0)`<br><br>• L'nT,w = 43.2 [dB] |
| `standardized_level_difference` | `function` | **DnT,w from R'w (EN 12354-1 Formula 5b, exact 0.32·V/Ss form).**<br>• `r_prime_w`: R'w [dB]<br>• `volume` V [m³], `separating_area` Ss [m²] | `dnt = standardized_level_difference(52.2, 50.0, 11.5)`<br><br>• DnT,w = 53.6 → 54 [dB] |
| `facade_sound_reduction` | `function` | **Predicted façade insulation D2m,nT (EN 12354-3 Formula 10/13).**<br>• `elements`: sequence of `FacadeElement`<br>• `area`: Façade S [m²], `volume`: Receiving V [m³]<br>• `delta_l_fs`: ΔLfs [dB] (Default: 0), `bands`<br>• `frequencies`: band centres [Hz] (Default: None; length must equal the band count), carried on the result for plotting | `fac = facade_sound_reduction(elements, area=11.3, volume=50.0, bands="octave")`<br><br>• `FacadePredictionResult` |
| `FacadePredictionResult` | `dataclass` | **Predicted façade airborne insulation (EN 12354-3:2000).**<br>• `r_prime`, `r_45`, `r_tr_s`, `d_2m_nt` per band<br>• `r_tr_s_w`, `d_2m_nt_w`, `c_tr` single numbers | `res = facade_sound_reduction(...)` |
| `radiated_sound_power` | `function` | **Radiated sound power LW (EN 12354-4 Formula 2/3).**<br>• `elements`, `lp_in`: Inside Lp,in [dB]<br>• `area`: Segment S [m²]<br>• `c_d`: Cd [dB] (Default: -6), `r_prime_cap`: optional R' cap [dB] (Default: None; the Annex G example footnote uses 40), `octave_bands` | `seg = radiated_sound_power(elements, lp_in=lp, area=200.0, c_d=-5.0)`<br><br>• `RadiatedPowerResult` |
| `RadiatedPowerResult` | `dataclass` | **Sound power radiated outside by a façade segment (EN 12354-4).**<br>• `l_w`, `r_prime` per band<br>• `l_w_dba`: A-weighted total | `res = radiated_sound_power(...)` |
| `outdoor_attenuation` | `function` | **Finite radiating-side attenuation A'tot (EN 12354-4 Annex E).**<br>• `width`, `height`: Side dimensions [m]<br>• `distance`: Perpendicular d [m] | `a = outdoor_attenuation(60.0, 10.0, 5.0)`<br><br>• A'tot = 26.3 [dB] |
| `outdoor_level` | `function` | **Exterior level Lp (EN 12354-4 Formula E.1).**<br>• `l_w`: Radiated LW [dB], scalar or per-side<br>• `attenuation`: A'tot [dB], scalar or per-side | `lp = outdoor_level(62.9, 26.3)`<br><br>• Lp = 36.6 [dB] |
| `facade_shape_level_difference` | `function` | **Façade-shape term ΔLfs (EN 12354-3 Annex C, Figure C.2).**<br>• `shape`: 'plane_facade'/'gallery_2'…'gallery_5'/'balcony_6'…'balcony_8'/'terrace_open'/'terrace_closed'<br>• `line_of_sight` h [m] (Default: 0), `absorption` αw (Default: 0.3, interpolated) | `dlfs = facade_shape_level_difference('balcony_6', line_of_sight=2.0, absorption=0.6)`<br><br>• ΔLfs = 1 [dB] |
| `FacadeElement` | `dataclass` | **One façade transmission path (EN 12354-3/-4).**<br>• give exactly one of `r`: area element [dB] / `dn_e`: small element [dB] / `insertion_loss`: opening [dB]<br>• `area`: Sᵢ [m²] (for `r` / `insertion_loss`) | `FacadeElement("window", area=4.5, r=[23, 22, 30, 36, 37])` |
| `AirbornePredictionResult` | `dataclass` | **Predicted airborne insulation.**<br>• `r_prime_w`: Apparent R'w [dB]<br>• `r_direct_w`: Direct RDd,w [dB]<br>• `paths`: tuple of `PathContribution`<br>• `dominant`: highest-energy path | `res.r_prime_w, res.dominant.label` |
| `ImpactPredictionResult` | `dataclass` | **Predicted impact insulation.**<br>• `l_prime_n_w`: Apparent L'n,w [dB]<br>• `ln_w_eq`, `delta_l_w`, `k_correction` [dB] | `imp.l_prime_n_w` |
| `FlankingPath` | `dataclass` | **One flanking transmission path.**<br>• `label`, `kind`: 'Ff'/'Df'/'Fd'<br>• `r_ij_w`: Flanking index Rij,w [dB] | `p.r_ij_w` |
| `PathContribution` | `dataclass` | **A path with its energy share.**<br>• `label`, `kind`: 'Dd'/'Ff'/'Df'/'Fd'<br>• `r_w`: Path index [dB]<br>• `fraction`: share of transmitted energy (0–1) | `c.r_w, c.fraction` |
| `vibration_reduction_index` | `function` | **Measured vibration reduction index Kij (ISO 10848 Formula 13/14).**<br>• `velocity_level_difference`: direction-averaged D̄v,ij [dB]<br>• `junction_length` lij [m], `area_i`/`area_j` [m²]<br>• `frequency` [Hz], `structural_reverberation_time_i`/`_j` [s] (both → Formula 13; else simplified 14)<br>• `speed_of_sound` (Default: 343)<br>• `modal_overlap`: per-band M; M < 0.25 bands bracketed and excluded from K̄ij (ISO 10848-4 Clause 9) | `res = vibration_reduction_index(dbar, 4.0, 12.0, 10.0, frequency=f, structural_reverberation_time_i=0.35, structural_reverberation_time_j=0.40)`<br><br>• `VibrationReductionResult` |
| `direction_averaged_level_difference` | `function` | **Direction average D̄v,ij = ½(Dv,ij+Dv,ji) (ISO 10848 Formula 11).**<br>• `dv_ij`, `dv_ji` [dB] | `dbar = direction_averaged_level_difference(dv_ij, dv_ji)` |
| `velocity_level_difference` | `function` | **Velocity level difference Dv,ij = Lv,i−Lv,j (ISO 10848 Formula 8).**<br>• `source_level`, `receive_level` [dB] | `dv = velocity_level_difference(lv_i, lv_j)` |
| `equivalent_absorption_length` | `function` | **Equivalent absorption length aj (ISO 10848 Formula 12).**<br>• `area` Sj [m²], `structural_reverberation_time` Ts [s], `frequency` [Hz]<br>• `speed_of_sound` (Default: 343) | `a = equivalent_absorption_length(10.0, 0.5, f)` |
| `total_loss_factor` | `function` | **Total loss factor η = 2.2/(f·Ts) (ISO 10848 Clause 7.3.1).**<br>• `frequency` [Hz], `structural_reverberation_time` [s] | `eta = total_loss_factor(f, ts)` |
| `normalized_flanking_level_difference` | `function` | **Airborne Dn,f = L1−L2−10 lg(A/A0) (ISO 10848 Formula 4).**<br>• `source_level` L1, `receive_level` L2 [dB]<br>• `absorption_area` A [m²]<br>• `reference_area` A0 (Default: 10) | `res = normalized_flanking_level_difference(l1, l2, a)`<br><br>• `FlankingLevelDifferenceResult` |
| `normalized_flanking_impact_level` | `function` | **Impact Ln,f = L2+10 lg(A/A0) (ISO 10848 Formula 5).**<br>• `receive_level` L2 [dB], `absorption_area` A [m²]<br>• `reference_area` A0 (Default: 10) | `res = normalized_flanking_impact_level(l2, a)`<br><br>• `FlankingImpactLevelResult` |
| `vibration_reduction_index_from_flanking` | `function` | **Indirect Kij from Dn,f (ISO 10848 Clause 4.3.1).**<br>• `normalized_flanking_level_difference` Dn,f, `reduction_index_i`/`_j` Ri/Rj [dB]<br>• `junction_length`, `area_i`/`area_j`, `absorption_length_i`/`_j` | `k = vibration_reduction_index_from_flanking(dnf, ri, rj, 2.0, 10.0, 12.0, ai, aj)`<br><br>• Kij [dB] |
| `critical_frequency` | `function` | **Thin-plate critical frequency fc = c0²/(1.8·cL·h·π) (ISO 10848 Formula 20).**<br>• `longitudinal_wave_speed` cL [m/s], `thickness` h [m]<br>• `speed_of_sound` (Default: 343) | `fc = critical_frequency(5500.0, 0.2)` |
| `strong_coupling_satisfied` | `function` | **Strong-coupling validity check (ISO 10848 Formula 15).**<br>• `velocity_level_difference` D̄v,ij [dB]<br>• `mass_i`/`mass_j` [kg/m²], `critical_frequency_i`/`_j` [Hz] | `ok = strong_coupling_satisfied(dbar, 20, 20, 100, 100)`<br><br>• bool per band |
| `modal_density` | `function` | **Modal density n = π·S·fc/c0² (ISO 10848-4 Formula 5).**<br>• `area` S [m²], `critical_frequency` fc [Hz]<br>• `speed_of_sound` (Default: 343) | `n = modal_density(10.0, 200.0)` |
| `modal_overlap_factor` | `function` | **Modal overlap M = 2.2·n/Ts (ISO 10848-4 Formula 6).**<br>• `area`, `critical_frequency`, `structural_reverberation_time` [s] | `m = modal_overlap_factor(10.0, 200.0, ts)  # feed vibration_reduction_index(modal_overlap=m)` |
| `band_mode_count` | `function` | **In-band mode count N = 0.23·f·n (ISO 10848-4 Formula 4).**<br>• `frequency` [Hz], `area`, `critical_frequency` | `n5 = band_mode_count(f, 10.0, 200.0)  # N≥5 OK` |
| `VibrationReductionResult` | `dataclass` | **Measured Kij (ISO 10848).**<br>• `frequencies` [Hz] (or None), `k_ij` [dB]<br>• `single_number`: mean K̄ij 200–1250 Hz (thirds) / 125–1000 Hz (octaves), or None<br>• `bracketed`: M < 0.25 flags (or None)<br>• `.octave_bands()`, `.plot()` | `res.k_ij, res.single_number` |
| `FlankingLevelDifferenceResult` | `dataclass` | **Normalized flanking level difference Dn,f (ISO 10848).**<br>• `d_n_f` [dB], `rating`: Dn,f,w (or None)<br>• `.plot()` | `res.d_n_f, res.rating` |
| `FlankingImpactLevelResult` | `dataclass` | **Normalized flanking impact level Ln,f (ISO 10848).**<br>• `l_n_f` [dB], `rating`: Ln,f,w (or None)<br>• `.plot()` | `res.l_n_f, res.rating` |
| `mass_law_transmission_loss` / `field_incidence_correction` | `function` | **Panel mass law TL (Bies Eq. 7.40/7.42).**<br>• `frequency` [Hz], `mass_per_area` m'' [kg/m²]<br>• `incidence`: 'normal'/'field', `band`: 'third' (−5.5 dB) / 'octave' (−4.0 dB)<br>• +6 dB/octave and +6 dB/mass doubling | `tl = mass_law_transmission_loss(500, 20)` |
| `single_panel_transmission_loss` | `function` | **Single-panel R, Sharp's method (Bies 7.2.4.1).**<br>• `frequency` [Hz], `mass_per_area` [kg/m²]<br>• `critical_frequency` fc [Hz] or `bending_stiffness` B' [N·m]<br>• `loss_factor` η, `band` | `r = single_panel_transmission_loss(f, 15, critical_frequency=2100)`<br><br>• `SoundReductionResult` |
| `double_wall_transmission_loss` / `mass_spring_mass_resonance` | `function` | **Double-wall R (Bies 7.2.6, Eq. 7.62-7.64).**<br>• `mass1`/`mass2` [kg/m²], `gap` d [m]<br>• `cavity_medium`: porous fill (`PorousMediumResult`) lowers f0<br>• below f0 = total-mass law; f0 = mass-air-mass resonance | `r = double_wall_transmission_loss(f, 12, 12, 0.1)`<br><br>• `SoundReductionResult` |
| `SoundReductionResult` | `dataclass` | **Predicted R(f) of a construction (Bies 7.2).**<br>• `transmission_loss` R [dB], `transmission_coefficient` τ<br>• `critical_frequency` / `resonance_frequency`<br>• `.rating()` → Rw (ISO 717-1), `.plot()` | `res.transmission_loss, res.rating().rating` |
| `slit_transmission_coefficient` / `slit_resonance_frequencies` | `function` | **Straight slit τ (Hopkins Eq. 4.99, Gomperts).**<br>• `frequency` [Hz], `width` w [m], `depth` d [m]<br>• `field`: 'diffuse'/'normal', `position`: 'mid'/'edge'<br>• maxima at d+2e = zλ/2 | `res = slit_transmission_coefficient(f, 0.002, 0.1)`<br><br>• `ApertureTransmissionResult` |
| `circular_aperture_transmission_coefficient` | `function` | **Circular hole τ (Hopkins Eq. 4.102, Wilson-Soroka).**<br>• `frequency` [Hz], `radius` a [m], `depth` d [m]<br>• τ → 1 for a large hole | `res = circular_aperture_transmission_coefficient(f, 0.01, 0.002)`<br><br>• `ApertureTransmissionResult` |
| `composite_transmission_loss` / `transmission_loss_from_coefficient` | `function` | **Composite R of parallel elements (Hopkins Eq. 4.92).**<br>• `areas` Sₙ [m²], `reduction_indices` Rₙ [dB] (1-D or (N, bands))<br>• a bare opening (Sₐ/S) caps R at 10 lg(S/Sₐ) | `r = composite_transmission_loss([0.99, 0.01], [55, 0])` |
| `ApertureTransmissionResult` | `dataclass` | **Slit / hole transmission (Hopkins 4.3.10).**<br>• `transmission_coefficient` τ, `transmission_loss` R = −10 lg(τ)<br>• `kind`: 'slit'/'circular', `.plot()` | `res.transmission_loss` |
| `band_uncertainty` | `function` | **One-third-octave standard uncertainty u (ISO 12999-1 Tables 2/4/6).**<br>• `measurand`: 'airborne'/'impact'/'impact_reduction'<br>• `situation`: 'A'/'B'/'C'<br>• `upper_limit`: σR95 (airborne A, Annex D) (Default: False) | `u = band_uncertainty('airborne', 'B')`<br><br>• `BandUncertainty` |
| `single_number_uncertainty` | `function` | **Single-number standard uncertainty u (ISO 12999-1 Tables 3/5/7).**<br>• `quantity`: 'r_w'/'ln_w'/'delta_lw' (+ aliases, +c/+ctr variants)<br>• `situation`: 'A'/'B'/'C'<br>• `upper_limit` (Default: False) | `u = single_number_uncertainty('r_w', 'B')`<br><br>• u [dB] (0.9) |
| `single_number_uncertainty_uncorrelated` | `function` | **Uncorrelated single-number u from bands (ISO 12999-1 Formula B.2).**<br>• `band_uncertainties`: per-band u_i [dB]<br>• `reference_differences`: L_i − R_i [dB] | `u = single_number_uncertainty_uncorrelated(u_i, d_i)`<br><br>• Energy-weighted quadrature u [dB] |
| `maximum_repeatability_standard_deviation` | `function` | **Max repeatability σx per band (ISO 12999-1 Table 1).**<br>• (no parameters) | `b = maximum_repeatability_standard_deviation()`<br><br>• `BandUncertainty` (lab self-verification) |
| `insulation_coverage_factor` | `function` | **Coverage factor k (ISO 12999-1 Table 8).**<br>• `confidence`: fraction (Default: 0.95)<br>• `one_sided` (Default: False) | `k = insulation_coverage_factor(0.95)`<br><br>• 1.96 (two-sided) / 1.65 (one-sided) |
| `insulation_expanded_uncertainty` | `function` | **Expanded uncertainty U = k·u (ISO 12999-1 Formula 2).**<br>• `u` [dB]<br>• `coverage`: fraction (Default: 0.95)<br>• `one_sided` (Default: False); enforces k ≥ 1 | `U = insulation_expanded_uncertainty(0.9)`<br><br>• 1.764 [dB] |
| `uncertain_value` | `function` | **Attach U to a rating (ISO 12999-1 Clause 8).**<br>• `value` [dB], `quantity`, `situation`<br>• `coverage` (Default: 0.95), `one_sided` (Default: False), `upper_limit` (Default: False) | `uv = uncertain_value(52.0, 'rprime_w', 'B')`<br><br>• `UncertainValue` (value ± U) |
| `combine_uncertainties` | `function` | **Quadrature combination (ISO 12999-1 Formula C.2).**<br>• `*components`: non-negative u_i [dB] | `uc = combine_uncertainties(1.0, 0.6)`<br><br>• sqrt(Σ u_i²) = 1.166 [dB] |
| `prediction_input_uncertainty` | `function` | **Prediction input uncertainty (ISO 12999-1 Formula A.1).**<br>• `sigma_reproducibility`, `sigma_product` [dB]<br>• `n`: measurements (≥ 1) | `u = prediction_input_uncertainty(1.8, 1.0, 3)`<br><br>• sqrt((σR²+σp²)/n + σp²) [dB] |
| `reduce_by_independent_measurements` | `function` | **Reduce u by m measurements (ISO 12999-1 Formula A.7).**<br>• `u` [dB]<br>• `m`: independent measurements (≥ 1) | `ur = reduce_by_independent_measurements(1.0, 4)`<br><br>• u/√m = 0.5 [dB] |
| `satisfies_lower_requirement` | `function` | **Conformity to a minimum (ISO 12999-1 Formula 5).**<br>• `value`, `expanded_uncertainty_value`, `requirement` [dB] | `ok = satisfies_lower_requirement(52.0, 1.485, 50.0)`<br><br>• True when value − U > requirement |
| `satisfies_upper_requirement` | `function` | **Conformity to a maximum (ISO 12999-1 Formula 4).**<br>• `value`, `expanded_uncertainty_value`, `requirement` [dB] | `ok = satisfies_upper_requirement(45.0, 1.5, 50.0)`<br><br>• True when value + U < requirement |
| `BandUncertainty` | `dataclass` | **Per-band standard uncertainty (ISO 12999-1).**<br>• `measurand`, `situation`<br>• `frequencies` [Hz], `uncertainties` [dB]<br>• `upper_limit`: σR95 flag<br>• `.to_arrays()` | `b.frequencies, b.uncertainties` |
| `UncertainValue` | `dataclass` | **A value with its expanded uncertainty.**<br>• `value`, `standard_uncertainty`, `expanded_uncertainty` [dB]<br>• `coverage_factor`, `confidence`, `one_sided`<br>• `.lower` = y − U, `.upper` = y + U | `uv.lower, uv.upper` |
| `COVERAGE_FACTORS` | `mapping` | **Table 8 coverage factors (read-only).**<br>Keyed by `(confidence, one_sided)` → k | `COVERAGE_FACTORS[(0.95, False)]  # 1.96` |
| `sound_absorption_coefficient_uncertainty` | `function` | **Absorption-coefficient uncertainty αs (ISO 12999-2 Table 1, Formula 1).**<br>• `alpha`, `frequencies` [Hz] (1/3-oct 63–5000)<br>• `condition`: 'reproducibility'/'repeatability'<br>• `confidence` (Default: 0.95) | `r = sound_absorption_coefficient_uncertainty(a_s, f)`<br><br>• `AbsorptionUncertaintyResult` |
| `equivalent_area_uncertainty` | `function` | **Equivalent-area uncertainty AT (ISO 12999-2 Formula 2, S = 10 m²).**<br>• `area`, `frequencies` [Hz]<br>• `condition`, `confidence` (Default: 0.95) | `r = equivalent_area_uncertainty(a_t, f)`<br><br>• `AbsorptionUncertaintyResult` |
| `practical_coefficient_uncertainty` | `function` | **Practical-coefficient uncertainty αp (ISO 12999-2 Table 2, Formula 4).**<br>• `alpha_p`, `frequencies` [Hz] (octave 250–4000)<br>• `condition`, `confidence` (Default: 0.95) | `r = practical_coefficient_uncertainty(a_p, f)`<br><br>• `AbsorptionUncertaintyResult` |
| `weighted_coefficient_uncertainty` | `function` | **Weighted-coefficient uncertainty αw (ISO 12999-2 Formulae 6/7).**<br>• `alpha_w` (carried for the interval)<br>• `condition`, `confidence` | `weighted_coefficient_uncertainty(0.70)`<br><br>• σR = 0.035 / σr = 0.020 |
| `single_number_rating_uncertainty` | `function` | **DLα,NRD uncertainty (ISO 12999-2 Formulae 8/9, EN 1793-1).**<br>• `dl_alpha` [dB] (≥ 0)<br>• `condition`, `confidence` | `single_number_rating_uncertainty(8.1)`<br><br>• σR = 0.10·DLα / σr = 0.02·DLα |
| `absorption_coverage_factor` | `function` | **Coverage factor k (ISO 12999-2 Table 3, rounded).**<br>• `confidence`: 0.68/0.80/0.90/0.95/0.99/0.999 | `absorption_coverage_factor(0.95)  # 2.0` |
| `AbsorptionUncertaintyResult` | `dataclass` | **Absorption uncertainty (ISO 12999-2).**<br>• `standard_uncertainty` u, `expanded_uncertainty` U = k·u<br>• `reported_expanded_uncertainty` (Clause 8 rounding)<br>• `.lower`, `.upper`, `.plot()` | `r.reported_expanded_uncertainty` |
| `sound_power_pressure` | `function` | **Sound power from surface pressure (ISO 3744/3746).**<br>• `levels_positions`: (NM, NB) SPL [dB]<br>• `surface`: 'hemisphere' / 'box'<br>• `radius` [m] or `dimensions`+`distance` [m]<br>• `reflecting_planes`: 1/2/3 (Default: 1)<br>• `background_levels`: for K1<br>• `frequencies` [Hz]: for LWA<br>• `reverberation_time`+`volume` / `absorption_area` / `mean_absorption_coefficient`+`room_surface`: for K2<br>• `grade`: 'engineering' (Default) / 'survey'<br>• `omc_uncertainty` [dB] (Default: 0) | `res = sound_power_pressure(levels, 'hemisphere', radius=1.5, frequencies=f)`<br><br>• `SoundPowerResult` |
| `measurement_positions` | `function` | **Hemisphere mic coordinates (ISO 3744 Annex B).**<br>• `surface`: 'hemisphere'<br>• `radius` [m]<br>• `reflecting_planes`: 1/2/3<br>• `tones`: Table B.1 vs B.2 (Default: True)<br>• `grade`: 'engineering'/'survey' | `xyz = measurement_positions('hemisphere', radius=1.5)`<br><br>• (N, 3) coordinates [m] |
| `background_noise_correction` | `function` | **Background correction K1 (ISO 3744 Eq. 16).**<br>• `source_levels` [dB]<br>• `background_levels` [dB]<br>• `grade`: 'engineering'/'survey' | `k1 = background_noise_correction(src, bg)`<br><br>• K1 per band [dB] |
| `environmental_correction` | `function` | **Environmental correction K2 (ISO 3744 Eq. A.2).**<br>• `surface_area` S [m²]<br>• `absorption_area` A, or `reverberation_time`+`volume`, or `mean_absorption_coefficient`+`room_surface` | `k2 = environmental_correction(14.1, reverberation_time=0.6, volume=300)`<br><br>• K2 [dB] |
| `SoundPowerResult` | `dataclass` | **Surface-pressure sound power.**<br>• `sound_power_level` [dB]<br>• `surface_pressure_level`, `mean_pressure_level` [dB]<br>• `background_correction`, `environmental_correction` [dB]<br>• `directivity_index` [dB] `(NM, NB)`<br>• `surface_area` [m²]<br>• `sound_power_level_a` [dB]<br>• `uncertainty` [dB]<br>• `grade`<br>• `declare(...)` → `NoiseEmissionDeclaration` | `res.sound_power_level, res.sound_power_level_a` |
| `OperatingModeDeclaration` | `dataclass` | **ISO 4871 dual-number values for one operating mode.**<br>• `mode`: column label<br>• `sound_power_level` L_WA [dB re 1 pW]<br>• `sound_power_uncertainty` K_WA [dB]<br>• `emission_pressure_level` L_pA, `emission_pressure_uncertainty` K_pA [dB] (optional)<br>• `verification_level` L_1 [dB] (optional)<br>• `declared_sound_power_level`: L_WAd = L_WA + K_WA (3.15)<br>• `verified`: L_1 ≤ L_WAd (6.2, combined form)<br>• `verified_dual`: L_1 ≤ round(L_WA) + round(K_WA) (6.2, dual-number form) | `OperatingModeDeclaration('Mode 1', 88.0, 2.0)` |
| `NoiseEmissionDeclaration` | `dataclass` | **ISO 4871:1996 noise emission declaration (machinery).**<br>• `modes`: `OperatingModeDeclaration` per mode<br>• `machine`, `operating_conditions` (clause 5)<br>• `noise_test_code`, `basic_standards` (clause 5 b)<br>• `form`: 'dual-number' (Default) / 'single-number'<br>• `.report(path, ...)`: ISO 4871 fiche → PDF | `NoiseEmissionDeclaration((m1, m2), machine='...').report('iso4871.pdf')` |
| `sound_power_reverberation` | `function` | **Reverberation-room LW, direct (ISO 3741).**<br>• `levels`: mean room SPL [dB] (1D or (NM, NB))<br>• `t60` [s]<br>• `volume` V [m³]<br>• `surface_area` S [m²]<br>• `frequencies` [Hz] (required)<br>• `background_levels`: for K1<br>• `temperature` [°C] (Default: 23)<br>• `static_pressure` [kPa] (Default: 101.325) | `rev = sound_power_reverberation(lp, t60, 200, 220, f)`<br><br>• `ReverberationSoundPowerResult` |
| `sound_power_comparison` | `function` | **Reverberation-room LW, comparison (ISO 3741).**<br>• `levels`, `levels_ref`: room SPL [dB]<br>• `lw_ref`: reference-source LW [dB]<br>• `frequencies` [Hz]<br>• `background_levels`, `background_levels_ref`<br>• `temperature`, `static_pressure` | `cmp = sound_power_comparison(lp, lp_ref, lw_ref)`<br><br>• `ReverberationSoundPowerResult` (method='comparison') |
| `ReverberationSoundPowerResult` | `dataclass` | **Reverberation-room sound power.**<br>• `frequencies` [Hz] (or `None`)<br>• `sound_power_level` [dB]<br>• `mean_pressure_level` [dB]<br>• `absorption_area` [m²]<br>• `waterhouse_correction` [dB]<br>• `background_correction`, `c1`, `c2` [dB]<br>• `speed_of_sound` [m/s]<br>• `sound_power_level_a` [dB]<br>• `method` | `rev.sound_power_level, rev.waterhouse_correction` |
| `sound_power_intensity` | `function` | **Sound power by intensity scanning (ISO 9614-2).**<br>• `normal_intensity`: (N_seg, N_bands) [W/m²]<br>• `areas`: segment Si [m²]<br>• `normal_intensity_2`: 2nd sweep (criterion 3)<br>• `pressure_levels`: for FpI<br>• `pressure_residual_index`: δpI0 [dB]<br>• `frequencies` [Hz]<br>• `band_type`: 'third' (Default)/'octave'<br>• `grade`: 'engineering'/'survey'<br>• `repeatability_limit`: override s | `ir = sound_power_intensity(scan1, areas, frequencies=f, band_type='octave')`<br><br>• `SoundPowerIntensityResult` |
| `SoundPowerIntensityResult` | `dataclass` | **Scanning sound power.**<br>• `partial_power`, `partial_power_level`<br>• `sound_power`, `sound_power_level` [dB] (NaN where `negative_band`)<br>• `negative_band`: per-band bool (True where P ≤ 0)<br>• `surface_pressure_intensity_index` (FpI)<br>• `negative_partial_power_index` (F+/-)<br>• `repeatability`, `dynamic_capability_index` (Ld)<br>• `achieved_grade`<br>• `surface_area`, `sound_power_level_a`, `grade` | `ir.sound_power_level, ir.achieved_grade` |
| `absorption_area` | `function` | **Equivalent absorption area (ISO 354 Eq. 5/7).**<br>• `t60`: T [s]<br>• `volume` V [m³]<br>• `temperature` [°C] (Default: 20)<br>• `speed_of_sound` [m/s] (overrides temperature)<br>• `m`: air attenuation [1/m] (Default: 0) | `A = absorption_area(t60, volume=200)`<br><br>• A [m²] |
| `absorption_coefficient` | `function` | **Sound absorption coefficient (ISO 354 Eq. 9).**<br>• `t1`, `t2`: empty / with-specimen T [s]<br>• `volume` V [m³]<br>• `sample_area` S [m²]<br>• `temperature1`/`temperature2` [°C]<br>• `speed_of_sound1`/`2`, `m1`/`m2` | `a = absorption_coefficient(t1, t2, 200, 10.8)`<br><br>• α_s (unclamped, may exceed 1) |
| `attenuation_from_alpha` | `function` | **ISO 9613-1 α → m (ISO 354 8.1.2.1).**<br>• `alpha`: attenuation [dB/m] | `m = attenuation_from_alpha(0.01)`<br><br>• m = α/(10 lg e) [1/m] |
| `measure_sound_absorption` | `function` | **Reverberation-room absorption measurement (ISO 354 Eq. 5/7/8/9).**<br>• `frequencies` [Hz], `t_empty` T1 [s], `t_specimen` T2 [s]<br>• `volume` V [m³], `area` S [m²]<br>• `temperature` [°C] (Default: 20), `humidity` [%]<br>• `speed_of_sound` [m/s], `m` [1/m] (Default: 0) | `r = measure_sound_absorption(f, t1, t2, volume=200, area=10.8)`<br><br>• `SoundAbsorptionMeasurement` |
| `SoundAbsorptionMeasurement` | `dataclass` | **Reverberation-room sound absorption (ISO 354).**<br>• `frequencies` [Hz], `t_empty`/`t_specimen` [s]<br>• `volume`, `area`, `temperature`, `humidity`, `speed_of_sound`<br>• `air_attenuation` m [1/m]<br>• `absorption_area_empty` A1, `absorption_area_with_specimen` A2 [m²]<br>• `alpha_s` (unclamped)<br>• `.equivalent_absorption_area` AT = A2−A1<br>• `.plot()` / `.report()` | `r.alpha_s, r.equivalent_absorption_area` |
| `air_attenuation` | `function` | **Atmospheric attenuation coefficient α (ISO 9613-1 Eq. 5).**<br>• `frequencies` [Hz]<br>• `temperature` [°C] (Default: 20)<br>• `relative_humidity` [%] (Default: 50)<br>• `pressure` [kPa] (Default: 101.325)<br>• `exact_midband`: snap to Table 1 midbands (Default: False) | `a = air_attenuation([1000, 4000], 20, 50)`<br><br>• α [dB/m] (×1000 = dB/km); out-of-range warns |
| `air_attenuation_m` | `function` | **ISO 354 power attenuation m from conditions (ISO 9613-1 + ISO 354).**<br>• Same parameters as `air_attenuation` | `m = air_attenuation_m([1000, 4000], 20, 50)`<br><br>• m = α/(10 lg e) [1/m] |
| `AtmosphericAbsorptionWarning` | `warning class` | **ISO 9613-1 out-of-range advisory.**<br>Emitted by `air_attenuation` when temperature/humidity/frequency leave the tabulated ranges or pressure exceeds 200 kPa; the result still returns | `warnings.simplefilter('error', AtmosphericAbsorptionWarning)` |
| `atmospheric_attenuation` | `function` | **Plottable ISO 9613-1 attenuation-coefficient curve.**<br>• Same parameters as `air_attenuation`<br>• `distance` d [m] (optional; enables total attenuation) | `res = atmospheric_attenuation([1000, 4000], 20, 50)`<br><br>• `AtmosphericAttenuation`; `res.plot()` |
| `AtmosphericAttenuation` | `dataclass` | **Atmospheric attenuation result (ISO 9613-1).**<br>• `frequencies` [Hz]<br>• `attenuation_coefficient` α [dB/m]<br>• `temperature`, `relative_humidity`, `pressure`<br>• `distance` [m] or `None`<br>• `.total_attenuation` A = α·d [dB] (ISO 9613-2 Aatm) or `None`<br>• `.plot()`: α (dB/km) vs frequency | `res.attenuation_coefficient, res.total_attenuation` |
| `geometric_divergence` | `function` | **Geometrical divergence Adiv (ISO 9613-2 Eq. 7).**<br>• `distance` d [m] | `a = geometric_divergence(100.0)`<br><br>• 20 lg(d/d₀) + 11 = 51 dB at 100 m |
| `atmospheric_absorption` | `function` | **Atmospheric absorption term Aatm (ISO 9613-2 Eq. 8)**, α at the exact base-10 midbands (the Table 2 convention).<br>• `distance` d [m]<br>• `frequencies` [Hz] (snapped to exact midbands)<br>• `temperature`/`humidity`/`pressure` | `aatm = atmospheric_absorption(200, bands)`<br><br>• α·d per band [dB] |
| `ground_attenuation` | `function` | **Ground effect Agr, general method (ISO 9613-2 7.3.1, Eq. 9).**<br>• `distance` d, `source_height` hs, `receiver_height` hr [m]<br>• `frequencies` [Hz]<br>• `ground_source`/`ground_middle`/`ground_receiver` G ∈ [0,1]<br>• `projected_distance` dp [m] | `agr = ground_attenuation(200, 2, 2, bands, 1, 1, 1)`<br><br>• As+Ar+Am per band [dB] (negative = gain) |
| `ground_attenuation_alternative` | `function` | **Ground effect Agr, A-weighted method (ISO 9613-2 7.3.2, Eq. 10).**<br>• `distance` d [m]<br>• `mean_height` hm [m] | `agr = ground_attenuation_alternative(200, 2)`<br><br>• 4.8 − (2hm/d)·(17+300/d) ≥ 0 [dB] |
| `directivity_omega` | `function` | **Solid-angle directivity index DΩ (ISO 9613-2 Eq. 11).**<br>• `source_height` hs, `receiver_height` hr, `projected_distance` dp [m] | `dw = directivity_omega(1.5, 1.5, 200)`<br><br>• 0…~3 dB (add to Dc for the 7.3.2 method) |
| `Barrier` | `dataclass` | **Barrier diffraction geometry (ISO 9613-2 7.4).**<br>• `source_to_edge` dss, `edge_to_receiver` dsr [m]<br>• `parallel_distance` a [m] (Default: 0)<br>• `edge_separation` e [m] (Default: None → single diffraction)<br>• `ground_reflections_by_image` (Default: False → C₂=20)<br>• `lateral` (Default: False → top-edge)<br>• `line_of_sight_clear` (Default: False; True gives z the negative sign of Eq. 16 when the sight line passes above the edge) | `b = Barrier(source_to_edge=101, edge_to_receiver=101)`<br><br>• `.is_double` when e is given |
| `barrier_attenuation` | `function` | **Barrier diffraction Dz (ISO 9613-2 Eq. 14).**<br>• `barrier`: `Barrier`<br>• `distance` d [m]<br>• `frequencies` [Hz] | `dz = barrier_attenuation(b, 200, bands)`<br><br>• 10 lg[3+(C₂/λ)C₃ z Kmet], capped 20/25 dB; 10 lg 3 at grazing (z = 0), decaying to ≥ 0 for negative z |
| `meteorological_correction` | `function` | **Meteorological correction Cmet (ISO 9613-2 Eq. 21/22).**<br>• `projected_distance` dp, `source_height` hs, `receiver_height` hr [m]<br>• `c0`: factor C₀ [dB] | `c = meteorological_correction(200, 1.5, 1.5, 2.0)`<br><br>• C₀[1 − 10(hs+hr)/dp] ≥ 0 [dB] |
| `outdoor_propagation_attenuation` | `function` | **Total octave-band attenuation A (ISO 9613-2 Eq. 4).**<br>• `distance` d, `source_height` hs, `receiver_height` hr [m]<br>• `frequencies` [Hz]<br>• `ground_source`/`ground_middle`/`ground_receiver` G<br>• `barrier`: `Barrier` or None<br>• `temperature`/`humidity`/`pressure` (Default: 20 °C/70 %/101.325 kPa, a Table 2 reference atmosphere)<br>• `projected_distance` dp [m] | `att = outdoor_propagation_attenuation(200, 2, 2, bands, 1, 1, 1)`<br><br>• `OutdoorAttenuation` term breakdown |
| `predicted_receiver_level` | `function` | **Predicted octave-band receiver level (ISO 9613-2 Eq. 3/6).**<br>• `sound_power_level` Lw [dB]<br>• same geometry/ground/barrier/atmosphere params as `outdoor_propagation_attenuation`<br>• `directivity_index` Di, `d_omega` DΩ [dB]<br>• `c0`: subtract Cmet (Default: None) | `lp = predicted_receiver_level(lw, 200, 2, 2, bands)`<br><br>• LfT(DW) = Lw + Dc − A per band [dB] |
| `OutdoorAttenuation` | `dataclass` | **Per-band ISO 9613-2 attenuation breakdown.**<br>• `frequencies` [Hz]<br>• `a_div`, `a_atm`, `a_gr`, `a_bar` [dB]<br>• `a_total` = Adiv+Aatm+Agr+Abar [dB]<br>• `d_omega` [dB]<br>• `.report()` one-page ISO 9613-2 prediction fiche (pass a `SourceEmission` for the A-weighted receiver level) | `att.a_div, att.a_bar, att.a_total` |
| `SourceEmission` | `dataclass` | **Source emission for the receiver-level fiche (ISO 9613-2 Eq. 3), report-time only.**<br>• `sound_power_level` Lw [dB]<br>• `directivity_index` Di [dB] (Default: 0)<br>• `d_omega` DΩ [dB] (Default: 0)<br>• `cmet` Cmet [dB] (Default: None) | `att.report("f.pdf", source_emission=SourceEmission(lw))` |
| `DEFAULT_FREQUENCIES` | `tuple` | **ISO 9613-2 nominal octave bands.**<br>(no parameters) | `DEFAULT_FREQUENCIES  # (63, …, 8000)` |
| `ground_effect` | `function` | **Spherical-wave ground effect ΔL (Weyl-Van der Pol; Attenborough 2e Eq. 2.40, Salomons Eq. 3.4).**<br>• `frequencies` [Hz], `source_height` hs, `receiver_height` hr, `distance` [m]<br>• `impedance` (normalized) or `flow_resistivity` σ [Pa·s/m²]<br>• `model` 'delany_bazley'/'miki' | `r = ground_effect(bands, 1, 1.5, 50, flow_resistivity=2e5)`<br><br>• `SphericalGroundResult` (ΔL, Q, Rp, F) |
| `spherical_reflection_coefficient` | `function` | **Spherical-wave reflection coefficient Q = Rp + (1−Rp)F(w) (Attenborough Eq. 2.40c; Salomons Eq. D.58).**<br>• `frequencies`, `normalized_impedance`, `source_height`, `receiver_height`, `distance` | `q = spherical_reflection_coefficient(bands, 12-6j, 1, 1.5, 50)` |
| `SphericalGroundResult` | `dataclass` | **Spherical-wave ground-effect result.**<br>• `excess_attenuation` ΔL [dB re free field]<br>• `reflection_coefficient` Q, `plane_reflection_coefficient` Rp, `boundary_loss` F<br>• `r_direct`, `r_reflected` [m]<br>• `.plot()` | `r.excess_attenuation, r.reflection_coefficient` |
| `fresnel_number` | `function` | **Barrier Fresnel number N = (2/λ)(A+B−d) (Bies 5e Eq. 5.134).**<br>• `source_to_edge` A, `edge_to_receiver` B, `direct_distance` d [m]<br>• `frequencies` [Hz] | `n = fresnel_number(101, 101, 200, bands)` |
| `kurze_anderson_attenuation` | `function` | **Kurze-Anderson barrier attenuation (Bies Eq. 5.138).**<br>• `fresnel_number` N | `kurze_anderson_attenuation(0.0)  # 5 dB` |
| `barrier_insertion_loss` | `function` | **Barrier insertion loss: Kurze-Anderson / exact half-plane / coherent ground four-path (Attenborough Ch. 9, Bies 5.3.5-5.3.7).**<br>• `frequencies`, `source_height`, `barrier_distance`, `barrier_height`, `receiver_distance`, `receiver_height`<br>• `method` 'exact'/'kurze_anderson'<br>• `thickness` e [m] (thick barrier)<br>• `ground_impedance` or `ground_flow_resistivity` | `il = barrier_insertion_loss(bands, 1, 50, 4, 100, 1.5, ground_flow_resistivity=2e5)`<br><br>• `BarrierInsertionLoss` |
| `BarrierInsertionLoss` | `dataclass` | **Per-band barrier insertion loss.**<br>• `insertion_loss` [dB], `fresnel_number`, `method`, `ground`<br>• `.plot()` (IL vs frequency) | `il.insertion_loss` |
| `linear_sound_speed_profile` | `function` | **Linear effective sound-speed profile c_eff(z) = c0 + g·z (Salomons Sec. 4.2).**<br>• `gradient` g [s⁻¹]<br>• `ground_speed` c0 [m/s], `max_height` [m] | `p = linear_sound_speed_profile(0.1)`<br><br>• `EffectiveSoundSpeedProfile` |
| `log_linear_sound_speed_profile` | `function` | **Logarithmic surface-layer profile c_eff(z) = c0 + b·ln(1+z/z0) (Salomons Eq. 4.5).**<br>• `b` [m/s] (downward > 0)<br>• `ground_speed` c0, `roughness_length` z0, `max_height` [m], `n_points` | `p = log_linear_sound_speed_profile(1.0)`<br><br>• `EffectiveSoundSpeedProfile` |
| `EffectiveSoundSpeedProfile` | `dataclass` | **Effective sound-speed profile c_eff(z).**<br>• `heights` z [m], `sound_speeds` [m/s], `description`<br>• `.speed_at(z)`, `.plot()` | `p.speed_at(10.0)` |
| `ray_curvature_radius` | `function` | **Radius of curvature of a ray in a linear gradient Rc = c0/(&#124;g&#124; cos θ0) (Salomons Sec. 4.4).**<br>• `gradient` g [s⁻¹]<br>• `ground_speed` c0 [m/s], `launch_angle_deg` θ0 | `ray_curvature_radius(0.1)  # 3430 m` |
| `shadow_zone_distance` | `function` | **Shadow-boundary distance in upward refraction √(2Rc)(√hs+√hr) (Salomons Sec. 4.4).**<br>• `gradient` g < 0 [s⁻¹]<br>• `source_height` hs, `receiver_height` hr [m]<br>• `ground_speed` c0 | `shadow_zone_distance(-0.1, 2, 2)` |
| `atmospheric_ray_paths` | `function` | **Ray tracing through a refracting atmosphere (Snell's law, Salomons Eq. 4.3).**<br>• `profile`: `EffectiveSoundSpeedProfile`<br>• `source_height` [m], `launch_angles_deg`<br>• `max_range` [m], `n_steps` | `r = atmospheric_ray_paths(p, source_height=2, launch_angles_deg=[-2,0,2])`<br><br>• `AtmosphericRayResult` |
| `AtmosphericRayResult` | `dataclass` | **Ray-tracing solution.**<br>• `ranges`, `heights`, `travel_times` [s]<br>• `turning_points`, `ground_reflections`<br>• `.plot()` (ray paths) | `r.heights, r.turning_points` |
| `atmospheric_parabolic_equation` | `function` | **Relative-level field from the Green's Function PE (Salomons App. H).**<br>• `frequency_hz`, `profile`, `source_height` [m]<br>• `impedance` or `flow_resistivity` σ, `model`<br>• `max_range`/`max_height`/`range_step`/`height_step` [m] | `pe = atmospheric_parabolic_equation(500, p, source_height=2, flow_resistivity=2e5)`<br><br>• `AtmosphericPEResult` |
| `AtmosphericPEResult` | `dataclass` | **GFPE relative-level field (dB re free field).**<br>• `ranges`, `heights`, `relative_level` (z×r)<br>• `normalized_impedance`<br>• `.level_at_height(z)`, `.plot()` | `pe.level_at_height(2.0)` |
| `task_based_exposure` | `function` | **Task-based daily exposure LEX,8h + U (ISO 9612 Clause 9).**<br>• `tasks`: sequence of `Task`<br>• `instrument`: 'class1'/'class2'/'personal_exposimeter' (Default)<br>• `u3` [dB] (Default: 1.0)<br>• `include_duration_uncertainty` (Default: True)<br>• `warn` (Default: True) | `res = task_based_exposure(tasks)`<br><br>• `ExposureResult` with per-task breakdown |
| `job_based_exposure` | `function` | **Job-based daily exposure LEX,8h + U (ISO 9612 Clause 10).**<br>• `samples` Lp,A,eqT [dB]<br>• `effective_duration_hours` Te [h]<br>• `instrument`, `u3`<br>• `n_workers`/`sample_duration_hours`: Table 1 check | `res = job_based_exposure(samples, 7.5)`<br><br>• `ExposureResult` (Eq C.9 / Table C.4 budget) |
| `full_day_exposure` | `function` | **Full-day daily exposure LEX,8h + U (ISO 9612 Clause 11).**<br>• `samples` whole-day Lp,A,eqT [dB]<br>• `effective_duration_hours` Te [h]<br>• `instrument`, `u3`, `warn` | `res = full_day_exposure(samples, 9.25)`<br><br>• `ExposureResult`; 3 samples spanning ≥3 dB advise |
| `Task` | `dataclass` | **One task of a task-based measurement (ISO 9612 Clause 9).**<br>• `samples` [dB], `duration_hours` [h]<br>• `duration_samples`/`duration_range` (for u1b)<br>• `label`, `instrument` | `Task(samples=(80.1, 82.2), duration_hours=5.0)` |
| `ExposureResult` | `dataclass` | **Daily exposure and uncertainty (ISO 9612).**<br>• `lex_8h` [dB]<br>• `combined_standard_uncertainty` u [dB]<br>• `expanded_uncertainty` U = 1.65 u [dB]<br>• `upper_limit` = LEX,8h + U [dB]<br>• `strategy`, `sampling_advisory`, `instrument`, `tasks`<br>• `.report(path, ...)`: ISO 9612 Clause 15 measurement report with the Directive 2003/10/EC assessment → PDF | `res.report("lex.pdf", metadata=ReportMetadata(client="..."))` |
| `TaskContribution` | `dataclass` | **Per-task breakdown inside `ExposureResult.tasks` (ISO 9612 Clause 9).**<br>• `label`, `lp_aeqt` (Eq. 7), `duration_hours`<br>• `lex_8h_contribution` (Eq. 8) [dB]<br>• uncertainty terms `u1a`, `u1b`, `c1a`, `c1b`, `u2`, `u3` | `res.tasks[0].lex_8h_contribution` |
| `table_c4_contribution` | `function` | **Sampling contribution c₁u₁ [dB] from Table C.4 (ISO 9612 job/full-day).**<br>• `n_samples`: N (clamped to [3, 30])<br>• `u1`: sampling standard uncertainty [dB]<br>• bilinear interpolation, anchored at u₁ = 0 | `table_c4_contribution(6, 2.0)  # 1.4` |
| `minimum_cumulative_duration_hours` | `function` | **Minimum cumulative measurement duration [h] (ISO 9612 Table 1).**<br>• `n_workers`: group size n_G (> 40 → 17 h; the standard advises splitting the group) | `minimum_cumulative_duration_hours(18)  # 10.75` |
| `COVERAGE_FACTOR` | `float` | **ISO 9612 coverage factor k = 1.65 (Clause 14).**<br>One-sided 95 % confidence: U = 1.65 u, upper limit LEX,8h + U | `COVERAGE_FACTOR  # 1.65` |
| `INSTRUMENT_U2` | `mapping` | **Instrument standard uncertainty u2 [dB] (ISO 9612 Table C.5).**<br>Keyed by `'class1'` (0.7), `'class2'` (1.5), `'personal_exposimeter'` (1.5) | `INSTRUMENT_U2['class1']  # 0.7` |
| `OccupationalExposureWarning` | `warning class` | **ISO 9612 sampling advisory.**<br>Emitted for the 3 dB task spread (Clause 9.3), c₁u₁ > 3.5 dB (Clause 10.4) or a short Table 1 cumulative duration | `warnings.simplefilter('error', OccupationalExposureWarning)` |
| `SoundPowerWarning` | `warning class` | **ISO 3744/3746/3741/9614-2 qualification issue.**<br>Emitted when the background margin is below the criterion, K2 exceeds the validity limit, a band's power is negative, or the room fails qualification; levels are then upper bounds | `warnings.simplefilter('error', SoundPowerWarning)` |
| `AbsorptionWarning` | `warning class` | **ISO 354 advisory.**<br>Emitted for a room below 150 m³, a sample area outside 10-12 m², an out-of-range temperature, or a non-physical α_s ≤ 0; the result still returns | `warnings.simplefilter('error', AbsorptionWarning)` |
| `speech_intelligibility_index` | `function` | **Speech Intelligibility Index (ANSI S3.5-1997, one-third-octave method).**<br>• `speech_spectrum`: 18 equivalent speech spectrum levels, 160 Hz–8 kHz [dB SPL], or a vocal-effort name 'normal'/'raised'/'loud'/'shout' (Table 3)<br>• `noise_spectrum`: equivalent noise spectrum levels [dB SPL] (Default: None → quiet, −80 dB)<br>• `threshold`: equivalent hearing threshold [dB HL] (Default: None → 0) | `res = speech_intelligibility_index("normal", noise_spectrum=[40.0]*18)`<br><br>• `SIIResult`; `res.sii` = 0.066 |
| `standard_speech_spectrum` | `function` | **Standard speech spectrum level by vocal effort (ANSI S3.5-1997 Table 3).**<br>• `vocal_effort`: 'normal', 'raised', 'loud' or 'shout' (Default: 'normal') | `u = standard_speech_spectrum('raised')`<br><br>• 18 band levels [dB SPL] |
| `standard_speech_spectra` | `function` | **Plottable standard speech spectra by vocal effort (ANSI S3.5-1997 Table 3).**<br>• `vocal_efforts`: a name or sequence of 'normal'/'raised'/'loud'/'shout' (Default: the full family) | `res = standard_speech_spectra()`<br><br>• `StandardSpeechSpectrum`; `res.plot()` |
| `StandardSpeechSpectrum` | `dataclass` | **Standard speech spectrum result.**<br>• `frequencies`: the 18 band centres [Hz]<br>• `vocal_efforts`: the efforts carried<br>• `levels`: `Ui` per effort, `(len(vocal_efforts), 18)` [dB SPL]<br>• `.plot()`: the speech spectrum level vs one-third-octave band, one line per effort | `res.levels, res.vocal_efforts` |
| `SIIResult` | `dataclass` | **SII result.**<br>• `sii`: index in [0, 1]<br>• `band_audibility`: Ai per band (§5.8)<br>• `band_importance`: Ii (Table 3)<br>• `frequencies` [Hz]<br>• `speech_spectrum` / `disturbance` / `masking`: Ei′ / Di / Zi per band [dB]<br>• `.plot()` | `res.sii, res.band_audibility` |
| `stoi` | `function` | **Short-time objective intelligibility STOI / ESTOI (Taal et al. 2011; Jensen & Taal 2016).**<br>• `clean`: clean reference (1D)<br>• `degraded`: degraded/processed signal (1D, same length)<br>• `fs` [Hz] (resampled to 10 kHz internally)<br>• `extended`: use ESTOI (Default: False) | `d = stoi(clean, degraded, fs)`<br><br>• `STOIResult`; `d.value` in ~[0, 1] (1 if identical) |
| `STOIResult` | `dataclass` | **STOI / ESTOI result.**<br>• `value`: the intelligibility index<br>• `extended`: True for ESTOI<br>• `segment_scores`: per-segment intermediate<br>• `band_scores`: per-band mean correlation (STOI; None for ESTOI)<br>• `band_frequencies` [Hz]<br>• `sample_rate` (10 kHz)<br>• `.plot()` | `res.value, res.band_scores` |
| `thd` | `function` | **Total harmonic distortion (IEC 60268-3 14.12.2–3).**<br>• `signal` (1D), `fs` [Hz]<br>• `fundamental` [Hz] (Default: None → largest peak)<br>• `kind`: 'F' rel. fundamental or 'R' rel. total RMS (Default: 'F')<br>• `n_harmonics` (Default: 10)<br>• `window` (Default: 'hann') | `thd(sig, fs, 1000.0, kind="F")`<br><br>• ratio (0..) |
| `harmonic_distortion` | `function` | **nth-order harmonic distortion dₙ (IEC 60268-3 14.12.5).**<br>• `signal`, `fs`, `fundamental` [Hz], `order` n (≥ 2) | `harmonic_distortion(sig, fs, 1000.0, 2)`<br><br>• ratio (aₙ rel. total RMS) |
| `thd_plus_noise` | `function` | **THD+N ratio (AES17-2015 6.3.1).**<br>• `signal`, `fs`, `fundamental` (Default: None)<br>• `notch_q`: effective Q of the applied notch, 1.2–3 (Default: 2.0)<br>• `bandwidth`: AES17 chain upper edge [Hz] (Default: 20000; None = full Nyquist); 20 Hz high-pass included<br>• `as_db`: 20·lg(ratio) dB (Default: False)<br>• `window`: FFT window for auto-detection (Default: 'hann') | `thd_plus_noise(sig, fs, 1000.0)`<br><br>• ratio, or dB if `as_db` |
| `sinad` | `function` | **Signal-to-noise-and-distortion ratio, dB (derived from AES17 THD+N).**<br>• `signal`, `fs`, `fundamental` (Default: None), `notch_q` (Default: 2.0), `bandwidth` (Default: 20000)<br>= −(THD+N in dB) | `sinad(sig, fs, 1000.0)`<br><br>• SINAD [dB] |
| `dynamic_range` | `function` | **Dynamic range (AES17-2015 6.4.1), dB CCIR-RMS.**<br>• `signal`, `fs`, `fundamental` (Default: None → 997 Hz)<br>• `notch_q` (Default: 2.0), `bandwidth` (Default: 20000)<br>• `full_scale`: digital full-scale peak amplitude (Default: 1.0)<br>= full-scale sine / CCIR-RMS-weighted notched residual | `dynamic_range(out, fs, 997.0)`<br><br>• dB CCIR-RMS |
| `idle_channel_noise` | `function` | **Idle channel noise level (AES17-2015 6.4.2), dBFS CCIR-RMS.**<br>• `signal` (idle output, 1D), `fs`<br>• `bandwidth` (Default: 20000)<br>• `full_scale` (Default: 1.0)<br>= CCIR-RMS-weighted RMS re full scale | `idle_channel_noise(idle, fs)`<br><br>• dBFS CCIR-RMS (−inf for digital zero) |
| `weighted_thd` | `function` | **Weighted THD (IEC 60268-3 14.12.11).**<br>• `signal`, `fs`, `fundamental` (Default: None)<br>• `weighting`: '468' (ITU-R BS.468-4 / IEC 60268-1, the 14.12.11 network), 'A' or 'C' (Default: '468')<br>• `notch_q` (Default: 2.0)<br>• valid for fundamentals 31.5–400 Hz | `weighted_thd(sig, fs, 100.0)`<br><br>• ratio |
| `itu_r_468_weighting` | `function` | **ITU-R BS.468-4 weighting response (Table 1).**<br>• `frequencies` [Hz] (≥ 0)<br>• log-frequency dB interpolation per the recommendation; DC → −inf | `itu_r_468_weighting([6300.0])  # +12.2`<br><br>• dB re 1 kHz |
| `modulation_distortion` | `function` | **Modulation distortion d_m,2 / d_m,3 (IEC 60268-3 14.12.7).**<br>• `signal`, `fs`, `f_low` [Hz], `f_high` [Hz]<br>• arithmetic sideband sums over the f₂ amplitude (14.12.7.2 g–h) | `modulation_distortion(sig, fs, 60.0, 7000.0).d2`<br><br>• `ModulationDistortionResult` |
| `ModulationDistortionResult` | `dataclass` | **Modulation distortion result.**<br>• `d2` / `d3`: the IEC per-order values<br>• `smpte`: combined-RMS analyzer convention (not an IEC quantity) | `res.d2, res.d3, res.smpte` |
| `difference_frequency_distortion` | `function` | **Difference-frequency distortion d_d,n (IEC 60268-3 14.12.8).**<br>• `signal`, `fs`, `f1` < `f2` [Hz]<br>• `order`: 2 or 3 (Default: 2)<br>• ref. U₂,ref = 2·U₂,f₂; 3rd order sums arithmetically | `difference_frequency_distortion(sig, fs, 13e3, 14e3, order=2)`<br><br>• ratio |
| `total_difference_frequency_distortion` | `function` | **Total difference-frequency distortion (IEC 60268-3 14.12.10).**<br>• `signal`, `fs`<br>• `f1` (Default: 8000), `f2` (Default: 11950) [Hz]<br>= √(a²_{f₂−f₁} + a²_{2f₁−f₂}) / (a_{f₁} + a_{f₂}) | `total_difference_frequency_distortion(sig, fs)`<br><br>• ratio |
| `dynamic_intermodulation_distortion` | `function` | **Dynamic intermodulation DIM (IEC 60268-3 14.12.9).**<br>• `signal`, `fs`<br>• `f_sine` [Hz] (Default: 15000)<br>• `f_square` [Hz] (Default: 3150) | `dynamic_intermodulation_distortion(sig, fs)`<br><br>• ratio (products rel. 15 kHz sine) |
| `harmonic_analysis` | `function` | **Full harmonic analysis (THD, THD+N, SINAD).**<br>• `signal`, `fs`, `fundamental` (Default: None)<br>• `n_harmonics` (Default: 10), `notch_q` (Default: 2.0), `bandwidth` (Default: 20000)<br>• `window`: FFT window (Default: 'hann') | `res = harmonic_analysis(sig, fs, 1000.0)`<br><br>• `HarmonicDistortionResult` |
| `HarmonicDistortionResult` | `dataclass` | **Harmonic analysis result.**<br>• `fundamental` [Hz]<br>• `harmonic_frequencies` / `harmonic_amplitudes`<br>• `thd_f` / `thd_r` / `thd_plus_noise`: ratios<br>• `sinad_db` [dB]<br>• `.plot()`: annotated harmonic spectrum | `res.thd_f, res.sinad_db` |
| `transfer_function` | `function` | **Frequency-response estimate H1/H2 (Bendat & Piersol).**<br>• `x` (input), `y` (output), `fs` [Hz]<br>• `estimator`: 'H1' or 'H2' (Default: 'H1')<br>• `nperseg` (Default: None), `overlap` (Default: 0.5) | `res = transfer_function(x, y, fs)`<br><br>• `FrequencyResponseResult` |
| `coherence` | `function` | **Ordinary coherence γ² (Bendat & Piersol).**<br>• `x`, `y`, `fs` [Hz]<br>• `nperseg` (Default: None), `overlap` (Default: 0.5)<br>= \|Gxy\|²/(Gxx·Gyy) ∈ [0, 1] | `f, g = coherence(x, y, fs)` |
| `FrequencyResponseResult` | `dataclass` | **Frequency-response result.**<br>• `frequencies` [Hz]<br>• `response`: complex H(f)<br>• `magnitude_db` / `phase` [rad]<br>• `coherence`: γ²(f)<br>• `estimator`<br>• `.plot()`: Bode + coherence | `res.magnitude_db, res.coherence` |
| `synchronized_sweep_signal` | `function` | **Synchronized exponential sweep (Novak et al. 2015, Eqs. 47/49).**<br>• `fs`, `f1` < `f2` [Hz], `seconds` (quantized by the rounding)<br>• `amplitude` (Default: 1.0), `fade` (Default: 0.0)<br>Rate L = round(f1·T̃/ln(f2/f1))/f1 → f1·L integer, so a L·ln(n) shift equals the nth harmonic and harmonic phases become system properties | `x = synchronized_sweep_signal(48000, 20.0, 6000.0, 4.0)`<br><br>• sweep samples (duration L·ln(f2/f1)) |
| `swept_sine_distortion` | `function` | **Harmonic separation and THD(f) from one sweep (Farina 2000 / Novak et al. 2015).**<br>• `recorded`, `fs`, plus the generator parameters `f1`, `f2`, `seconds`<br>• `method`: 'synchronized' (Default; analytic deconvolution, meaningful phases) / 'farina' (classical ESS, magnitudes only)<br>• `n_harmonics` (Default: 5), `ir_length` (Default: largest power of two fitting the closest arrivals)<br>• `amplitude` (Default: 1.0), `fade` (Default: generator default), `remove_dc` (Default: True) | `res = swept_sine_distortion(y, fs, 20.0, 6000.0, 4.0)`<br><br>• `SweptSineDistortionResult` |
| `SweptSineDistortionResult` | `dataclass` | **Swept-sine harmonic separation.**<br>• `frequencies` [Hz], `harmonic_responses`: complex H₁..H_N, `harmonic_irs` (windowed, centred), `delays` L·ln(n) [s]<br>• `thd_frequencies` [Hz], `thd`, `distortion_ratios`: \|Hₙ(n·f)\|/\|H₁(f)\| per order<br>• `fs`, `f1`, `f2`, `duration`, `rate` L, `method`, `n_harmonics`<br>• `.plot()`: \|Hₙ\| magnitudes + THD(f) | `res.thd, res.harmonic_responses` |
| `radiating_piston` | `function` | **Radiation of a rigid baffled circular piston (Beranek & Mellow §4.19/§13.7).**<br>• `radius` a [m], `frequencies` [Hz]<br>• `speed_of_sound` (Default: 343), `density` (Default: 1.206)<br>• `angles`: directivity polar angles [rad] (Default: None) | `res = radiating_piston(0.1, freqs)`<br><br>• `RadiatingPistonResult` |
| `piston_resistance` / `piston_reactance` | `function` | **Normalized piston radiation impedance R1 = 1−2J1(x)/x, X1 = 2H1(x)/x, x = 2ka (Beranek & Mellow Eqs. 13.117/13.118).** | `piston_resistance(2.0)  # 0.4233` |
| `piston_directivity` | `function` | **Far-field piston directivity 2·J1(ka·sinθ)/(ka·sinθ).**<br>• `ka`, `theta` [rad] | `piston_directivity(5.0, 0.3)` |
| `piston_directivity_pattern` | `function` | **Far-field directivity (beam) pattern of one or more baffled pistons.**<br>• `ka` (scalar or 1-D)<br>• `angles` [rad] (Default: front hemisphere −90°…+90°) | `p = piston_directivity_pattern([3, 8, 16])`<br><br>• `PistonDirectivity` |
| `RadiatingPistonResult` | `dataclass` | **Piston radiation result.**<br>• `ka`, `resistance`/`reactance`, `radiation_resistance`/`radiation_reactance` [N·s/m], `radiation_mass` = 8ρa³/3 [kg], `directivity_index` [dB], `directivity`<br>• `.plot()`: R1/X1 vs ka | `res.radiation_mass` |
| `PistonDirectivity` | `dataclass` | **Piston directivity-pattern result.**<br>• `angles` [rad], `ka`, `directivity` (linear), `directivity_db`<br>• `.plot()`: polar beam pattern in dB (family over ka) | `p.plot()` |
| `loudspeaker_characteristics` | `function` | **Rated loudspeaker characteristics for an IEC 60268-5 report.**<br>• `frequencies`, `spl_db` (on-axis SPL), `rated_impedance` R [Ω]<br>• `input_voltage` (Default: √R → 1 W), `distance` (Default: 1 m), `sensitivity_band`, `tolerance_db`<br>• `impedance`, `distortion`, `directivity`/`polar` panels | `res = loudspeaker_characteristics(f, spl, 8.0)`<br><br>• `LoudspeakerCharacteristics` |
| `LoudspeakerCharacteristics` | `dataclass` | **Loudspeaker rated-characteristics result (IEC 60268-5).**<br>• `sensitivity_level_db` (1 W/1 m, 20.3/20.4), `effective_range` (21.2), `reference_level_db`, `characteristic_sensitivity_pa`, `minimum_impedance`<br>• `.report()`: IEC 60268-5 fiche (graphs to IEC 60263) | `res.effective_range` |
| `microphone_characteristics` | `function` | **Rated microphone characteristics for an IEC 60268-4 report.**<br>• `frequencies`, `response_db` (free-field, re the reference frequency), `sensitivity_mv_per_pa` M [mV/Pa]<br>• `reference_frequency` (Default: 1 kHz), `tolerance_db` (Default: 2), `rated_impedance`/`minimum_load_impedance` [Ω], `noise_voltage` or `equivalent_noise_level_db`, `max_spl_db`/`max_spl_thd_percent`<br>• `distortion`, `noise_spectrum`, `polar` panels; `powering`, `supply_current_ma` | `res = microphone_characteristics(f, resp, 12.5)`<br><br>• `MicrophoneCharacteristics` |
| `MicrophoneCharacteristics` | `dataclass` | **Microphone rated-characteristics result (IEC 60268-4).**<br>• `sensitivity_level_db` = 20·lg(M / 1 V/Pa) (11.1), `effective_range` (12.2), `directivity_index_db` (13.2.2), `equivalent_noise_level_db` (17.2), `max_spl_db` (15.2), `signal_to_noise_ratio_db`, `diffuse_field_sensitivity_level_db`<br>• `.report()`: IEC 60268-4 fiche (graphs to IEC 60263) | `res.sensitivity_level_db` |
| `expansion_chamber` | `function` | **Expansion-chamber silencer TL/IL (Bies Eq. 8.111, four-pole).**<br>• `frequencies`, `length` L, `chamber_area`, `pipe_area`<br>• `source_impedance`/`radiation_impedance` (Default: None → no IL) | `res = expansion_chamber(f, 0.3, 0.04, 0.01)`<br><br>• `ReactiveSilencerResult` |
| `helmholtz_resonator` / `quarter_wave_resonator` | `function` | **Side-branch resonator silencers (Bies Eqs. 8.46/8.44).**<br>• Helmholtz: `duct_area`, `neck_area`, `neck_length`, `cavity_volume`<br>• Quarter-wave: `duct_area`, `length`, `branch_area` | `quarter_wave_resonator(f, 0.01, 1.5, 2e-3)` |
| `extended_tube_chamber` | `function` | **Extended-inlet/outlet expansion chamber (Bies §8.9.7); reduces to `expansion_chamber` at zero extension.**<br>• `length`, `chamber_area`, `pipe_area`, `inlet_extension`, `outlet_extension` | `extended_tube_chamber(f, 0.4, 0.04, 0.01, inlet_extension=0.1)` |
| `ReactiveSilencerResult` | `dataclass` | **Reactive-silencer result.**<br>• `frequencies`, `transmission_loss`, `insertion_loss` (or None), `transfer_matrix`, `kind`, `resonances`<br>• `.plot()`: TL/IL vs frequency | `res.transmission_loss` |
| `end_reflection_loss` / `elbow_insertion_loss` | `function` | **HVAC duct end reflection & bend insertion loss (Bies Tables 8.14/8.11, ASHRAE).**<br>• end: `frequencies`, `diameter`, `termination` 'flush'/'free'<br>• elbow: `frequencies`, `width`, `bend_type`, `vanes`, `lined` | `end_reflection_loss(bands, 0.3)`<br><br>• `HvacSpectrumResult` |
| `plenum_attenuation` | `function` | **Plenum-chamber TL by Wells' method (Bies Eq. 8.275).**<br>• `exit_area`, `line_of_sight`, `wall_area`, `mean_absorption`, `angle` | `plenum_attenuation(0.1, 1.0, 20.0, 0.2)  # dB` |
| `flow_noise_straight_duct` / `flow_noise_bend` | `function` | **HVAC flow-generated (self) noise sound power (VDI 2081, Bies Eqs. 8.251/8.254).**<br>• `frequencies`, `flow_velocity`, `area` (+ `height` for the bend) | `flow_noise_straight_duct(bands, 10.0, 0.04)`<br><br>• `HvacSpectrumResult` |
| `HvacSpectrumResult` | `dataclass` | **Per-frequency HVAC attenuation or regenerated Lw.**<br>• `frequencies`, `values`, `quantity`, `label`<br>• `.plot()` | `res.values` |
| `enclosure_insertion_loss` | `function` | **Machine-enclosure insertion loss IL = R − C (Bies Eqs. 7.103/7.111); panel R supplied by the caller, never predicted.**<br>• `panel_transmission_loss` (per-band array or callable of f)<br>• `external_area`, `internal_area`, `internal_absorption`<br>• `frequencies` (required for a callable R) | `enclosure_insertion_loss(R, 6.0, 5.0, 0.3)`<br><br>• `EnclosureResult` |
| `EnclosureResult` | `dataclass` | **Enclosure insertion-loss result.**<br>• `panel_transmission_loss`, `correction` C, `insertion_loss`, `room_constant`, `external_area`, `internal_area`<br>• `.plot()` | `res.insertion_loss` |
| `program_loudness` | `function` | **EBU R 128 measurement set of a programme (BS.1770-5 + Tech 3341/3342).**<br>• `x`: signal (1D mono or 2D [channels, samples], 1.0 = 0 dBFS)<br>• `fs` [Hz]<br>• `weights`: per-channel Gi (Default: Table 3 by channel count)<br>• `momentary_step` [s] (Default: 0.01), `short_term_step` [s] (Default: 0.1)<br>• `oversample`: true-peak factor (Default: None → reaches 192 kHz) | `res = program_loudness(x, fs)`<br><br>• `ProgramLoudnessResult` |
| `integrated_loudness` | `function` | **Programme (integrated) loudness with the two-stage gate (BS.1770-5 Annex 1).**<br>• `x`, `fs` [Hz]<br>• `weights` (Default: Table 3 by channel count)<br>Absolute gate −70 LKFS, relative −10 LU | `integrated_loudness(x, fs)  # LUFS` |
| `loudness_range` | `function` | **Loudness range LRA (EBU Tech 3342).**<br>• `short_term_loudness`: S readings [LUFS] at ≥ 10 Hz<br>Cascaded gate (−70 LUFS abs, −20 LU rel), 10th-95th percentile spread | `loudness_range(res.short_term)  # LU` |
| `true_peak_level` | `function` | **True-peak level (BS.1770-5 Annex 2).**<br>• `x` (1.0 = 0 dBFS), `fs` [Hz]<br>• `oversample`: factor ≥ 1 (Default: None → smallest reaching 192 kHz, 4 at 48 kHz) | `true_peak_level(x, fs)  # dBTP`<br>Scalar for 1D, per-channel array for 2D |
| `k_weighting` | `function` | **Apply the two-stage K-weighting pre-filter (BS.1770-5 Annex 1).**<br>• `x`, `fs` [Hz]<br>Spherical-head shelf + RLB high-pass | `y = k_weighting(x, fs)` |
| `k_weighting_coefficients` | `function` | **K-weighting biquads (Tables 1-2).**<br>• `fs` [Hz] (≥ 16 kHz)<br>Verbatim tables at 48 kHz; analog-prototype redesign elsewhere | `(b1, a1), (b2, a2) = k_weighting_coefficients(fs)` |
| `k_weighting_response` | `function` | **K-weighting magnitude frequency response (BS.1770-5 Annex 1).**<br>• `fs` [Hz] (≥ 16 kHz, Default: 48000)<br>• `frequencies` [Hz] (Default: None → `n` log points 10 Hz→Nyquist), `n` (Default: 512)<br>Evaluates the Table 1-2 biquads with `freqz` | `k_weighting_response().plot()`<br><br>• `KWeightingResponse` |
| `channel_weight` | `function` | **Position-dependent channel weight Gi (BS.1770-5 Annex 3, Table 4).**<br>• `azimuth` [deg], `elevation` [deg] (Default: 0)<br>1.41 for mid-layer side positions, 1.0 elsewhere | `channel_weight(110, 0)  # 1.41` |
| `DEFAULT_CHANNEL_WEIGHTS` | `dict` | **Table 3 weights by channel count.**<br>1/2/5/6 channels; 5.1 order L, R, C, LFE, Ls, Rs with the LFE at 0.0 (excluded) | `DEFAULT_CHANNEL_WEIGHTS[6]` |
| `ProgramLoudnessResult` | `dataclass` | **EBU Mode measurement result.**<br>• `integrated` [LUFS], `loudness_range` [LU], `true_peak` [dBTP]<br>• `momentary` / `short_term` series with `momentary_time` / `short_term_time` [s]<br>• `max_momentary` / `max_short_term` [LUFS]<br>• `relative_threshold`, `lra_low` / `lra_high` [LUFS]<br>• `true_peak_per_channel` [dBTP], `channel_weights`, `fs`<br>• `.plot()`: M/S over time with I and LRA annotated<br>• `.report(path, *, tolerance='qc'/'live')`: R 128 fiche (±0.2 LU item i / ±1.0 LU item h) | `res.integrated, res.loudness_range` |
| `KWeightingResponse` | `dataclass` | **K-weighting magnitude frequency response (BS.1770-5 Annex 1).**<br>• `frequencies` [Hz], `magnitude_db` (combined) [dB]<br>• `shelf_db` (stage 1, +4 dB shelf), `highpass_db` (stage 2, RLB) [dB]<br>• `fs` [Hz]<br>• `.plot()`: magnitude vs frequency (log axis) with the two stages | `k_weighting_response().magnitude_db` |
| `sound_pressure_level` | `function` | **Underwater SPL (ISO 18405), dB re 1 µPa.**<br>• `pressure`: signal (1D) [Pa]<br>• `reference`: p₀ (Default: 1e-6) | `sound_pressure_level(p)` |
| `sound_exposure_level` | `function` | **Underwater SEL (ISO 18405/18406), dB re 1 µPa²·s.**<br>• `pressure` (1D) [Pa]<br>• `fs` [Hz]<br>• `reference`: E₀ (Default: 1e-12) | `sound_exposure_level(p, fs)` |
| `peak_sound_pressure_level` | `function` | **Zero-to-peak level (ISO 18406), dB re 1 µPa.**<br>• `pressure` (1D) [Pa]<br>• `reference` (Default: 1e-6) | `peak_sound_pressure_level(p)` |
| `UNDERWATER_REFERENCE_PRESSURE` / `UNDERWATER_REFERENCE_EXPOSURE` | `float` | **ISO 18405 reference quantities.**<br>Reference pressure p0 = 1 µPa [Pa] and reference exposure E0 = 1 µPa²·s [Pa²·s] | `UNDERWATER_REFERENCE_PRESSURE  # 1e-06` |
| `underwater_to_in_air_spl` / `in_air_to_underwater_spl` | `function` | **Re-reference a level between 1 µPa and 20 µPa (∓26.02 dB).**<br>• `level` [dB]<br>Reference change only, not an energy equivalence. | `underwater_to_in_air_spl(120.0)` |
| `radiated_noise_level` | `function` | **Ship radiated noise level (ISO 17208-1), dB re 1 µPa·m.**<br>• `rms_pressure` [Pa]<br>• `distance` [m]<br>= 20·lg(p/p₀)+20·lg(r/r₀) | `radiated_noise_level(2e-6, 100.0)` |
| `hydrophone_depths` | `function` | **ISO 17208-1 hydrophone depths from depression angles.**<br>• `cpa_distance` [m]<br>• `angles` (Default: (15,30,45)) [deg]<br>= cpa·tan(angle) | `hydrophone_depths(100.0)` |
| `source_level_uncertainty` | `function` | **Tabulated source-level uncertainty (ISO 17208-2).**<br>• `frequency` [Hz]<br>5 dB ≤100 Hz / 3 dB 125 Hz–16 kHz / 4 dB >16 kHz | `source_level_uncertainty(1000.0)  # 3.0` |
| `monopole_source_level` | `function` | **Equivalent monopole source level (ISO 17208-2).**<br>• `rnl`: RNL [dB re 1 µPa·m] (scalar/array)<br>• `frequency` [Hz]<br>• `draught` D [m]<br>• `c` (Default: 1500) [m/s]<br>Ls = LRN + ΔL, d_s = 0.7·D | `res = monopole_source_level(rnl, f, 6.0)`<br><br>• `ShipSourceLevelResult` |
| `ShipSourceLevelResult` | `dataclass` | **Ship source-level result.**<br>• `frequencies` [Hz]<br>• `radiated_noise_level` / `surface_correction` / `source_level` [dB]<br>• `source_depth` [m] / `sound_speed` [m/s]<br>• `.plot()`: RNL/Ls/ΔL vs frequency | `res.source_level, res.surface_correction` |
| `single_strike_sel` | `function` | **Single-strike SEL (ISO 18406), dB re 1 µPa²·s.**<br>• `pressure`: one strike (1D) [Pa]<br>• `fs` [Hz] | `single_strike_sel(p, fs)` |
| `cumulative_sel` | `function` | **Cumulative SEL over strikes (ISO 18406).**<br>• `single_sels`: per-strike SELs [dB]<br>= 10·lg(Σ 10^(SELₙ/10)) | `cumulative_sel([170, 176, 173])` |
| `cumulative_sel_identical` | `function` | **Cumulative SEL of N identical strikes.**<br>• `sel_ss` [dB]<br>• `n_strikes` N (≥ 1)<br>= sel_ss + 10·lg(N) | `cumulative_sel_identical(180.0, 50)` |
| `pile_strike_metrics` | `function` | **Per-strike pile-driving metrics (ISO 18406).**<br>• `pressure`: one strike (1D) [Pa]<br>• `fs` [Hz] | `res = pile_strike_metrics(p, fs)`<br><br>• `PileStrikeResult` |
| `sea_water_sound_speed` | `function` | **Speed of sound in sea water, m/s.**<br>• `temperature` [°C] / `salinity` [ppt] / `depth` [m]<br>• `model`: `"unesco"` (default) / `"del_grosso"` / `"mackenzie"`<br>• `latitude` (Default: 45) | `sea_water_sound_speed(25, 35, 1000)` |
| `depth_to_pressure` | `function` | **Depth→pressure (Leroy-Parthiot 1998), MPa.**<br>• `depth` [m] (≥ 0)<br>• `latitude` (Default: 45) [°] | `depth_to_pressure(1000.0)` |
| `sound_speed_profile` | `function` | **Sound-speed profile over a depth column.**<br>• `depths` (1D, increasing) [m]<br>• `temperatures` / `salinities`: array or scalar<br>• `model` / `latitude` | `sound_speed_profile(z, T, S)`<br><br>• `SoundSpeedProfile` |
| `SoundSpeedProfile` | `dataclass` | **Sound-speed profile.**<br>• `depth` [m] / `sound_speed` [m/s] / `gradient` [(m/s)/m]<br>• `model`<br>• `.plot()`: speed vs depth (depth down) | `prof.sound_speed, prof.gradient` |
| `spreading_loss` | `function` | **Geometrical spreading loss, dB.**<br>• `range_m` [m] (scalar/array)<br>• `law`: `"spherical"` / `"cylindrical"` / `"practical"`<br>• `transition_range` [m] (for `"practical"`) | `spreading_loss(1000.0)` |
| `seawater_absorption` | `function` | **Volume absorption α, dB/km.**<br>• `frequency_hz` [Hz] (scalar/array)<br>• `temperature` [°C] / `salinity` [ppt] / `depth` [m] / `ph`<br>• `model`: `"francois-garrison"` (default) / `"ainslie-mccolm"` / `"thorp"` | `seawater_absorption(10e3)` |
| `transmission_loss` | `function` | **Transmission loss TL = spreading + α·R.**<br>• `range_m` [m] / `frequency_hz` [Hz]<br>• `law` / `temperature` / `salinity` / `depth` / `ph` / `model` / `transition_range` | `transmission_loss(r, 10e3)`<br><br>• `TransmissionLossResult` |
| `TransmissionLossResult` | `dataclass` | **Transmission-loss result.**<br>• `range_m` [m] / `tl` / `spreading` / `absorption` [dB]<br>• `frequency` [Hz] / `absorption_coefficient` [dB/km]<br>• `law` / `model`<br>• `.plot()`: TL vs range | `res.tl, res.absorption_coefficient` |
| `passive_sonar_equation` | `function` | **Passive sonar equation.**<br>• `source_level` / `transmission_loss` / `noise_level` [dB]<br>• `directivity_index` / `detection_threshold` (Default: 0)<br>SE = SL − TL − (NL − DI) − DT | `passive_sonar_equation(140, 80, 60)`<br><br>• `SonarEquationResult` |
| `active_sonar_equation` | `function` | **Active (monostatic) sonar equation.**<br>• `source_level` / `transmission_loss` / `target_strength` / `noise_level` [dB]<br>• `directivity_index` / `detection_threshold` / `reverberation_level`<br>noise-limited SE = SL − 2·TL + TS − (NL − DI) − DT; with `reverberation_level` → SE = SL − 2·TL + TS − RL − DT | `active_sonar_equation(220, 70, 15, 60)`<br><br>• `SonarEquationResult` |
| `SonarEquationResult` | `dataclass` | **Sonar-equation result.**<br>• `mode` / `signal_excess` / `snr` [dB] / `figure_of_merit` [dB]<br>• `transmission_loss` / `source_level` / `noise_level` / `directivity_index` / `detection_threshold` [dB]<br>• `target_strength` [dB or None] / `reverberation_limited`<br>• `.plot()`: signal excess vs TL | `res.signal_excess, res.figure_of_merit` |
| `critical_angle` | `function` | **Critical grazing angle φc = arccos(c1/c2), degrees.**<br>• `c1` / `c2` [m/s] (needs `c2 > c1`) | `critical_angle(1500, 1650)` |
| `reflection_coefficient` | `function` | **Complex seabed Rayleigh reflection coefficient.**<br>• `grazing_angle` [°] (scalar/array)<br>• `rho1` / `c1` / `rho2` / `c2` | `reflection_coefficient(30, rho1=1000, c1=1500, rho2=1900, c2=1650)` |
| `bottom_reflection_loss` | `function` | **Seabed reflection loss BL = −20·lg\|R\|, dB.**<br>• `grazing_angle` [°]<br>• `rho1` (Default: 1000) / `c1` (Default: 1500) / `rho2` / `c2` | `bottom_reflection_loss(phi, rho2=1900, c2=1650)`<br><br>• `BottomLossResult` |
| `BottomLossResult` | `dataclass` | **Seabed reflection result.**<br>• `grazing_angle` [°] / `reflection_loss` [dB]<br>• `reflection_coefficient` [complex] / `critical_angle` [° or None]<br>• `.plot()`: bottom loss vs grazing angle | `res.reflection_loss, res.critical_angle` |
| `seabed_reflection` | `function` | **Plottable seabed reflection coefficient (Rayleigh).**<br>• `grazing_angle` [°]<br>• `rho1` (Default: 1000) / `c1` (Default: 1500) / `rho2` / `c2` | `seabed_reflection(phi, rho2=1900, c2=1650)`<br><br>• `SeabedReflection` |
| `SeabedReflection` | `dataclass` | **Seabed reflection-coefficient result.**<br>• `grazing_angle` [°] / `reflection_coefficient` [complex]<br>• `magnitude` \|R\| / `bottom_loss` [dB] / `critical_angle` [° or None]<br>• `rho1` / `c1` / `rho2` / `c2`<br>• `.plot()`: \|R\| vs grazing angle | `res.magnitude, res.critical_angle` |
| `wind_noise_spectrum` | `function` | **Wenz wind noise (rule of fives), dB re 1 µPa²/Hz.**<br>• `frequency_hz` [Hz] / `wind_speed_knots` [kn] | `wind_noise_spectrum(1000, 5)` |
| `thermal_noise_spectrum` | `function` | **Mellen thermal noise, dB re 1 µPa²/Hz.**<br>• `frequency_hz` [Hz]<br>• `temperature` (Default: 16.85) / `density` (Default: 1025) / `sound_speed` (Default: 1500) | `thermal_noise_spectrum(5e4)` |
| `ocean_ambient_noise` | `function` | **Composite ambient-noise spectrum (wind + thermal [+ shipping]).**<br>• `frequency_hz` [Hz] / `wind_speed_knots` [kn]<br>• `shipping` [dB or None] / `temperature` / `density` / `sound_speed` | `ocean_ambient_noise(f, wind_speed_knots=10)`<br><br>• `AmbientNoiseResult` |
| `AmbientNoiseResult` | `dataclass` | **Ambient-noise result.**<br>• `frequency` [Hz] / `spectrum_level` [dB re 1 µPa²/Hz]<br>• `wind` / `thermal` / `shipping` [dB or None]<br>• `wind_speed_knots` [kn]<br>• `.plot()`: composite + components vs frequency | `res.spectrum_level` |
| `ship_source_spectrum` | `function` | **Predicted ship source-level spectrum.**<br>• `speed_knots` (Default: 12) / `length_m` (Default: 100)<br>• `vessel_class` (Default: `"containership"`) / `model`: `"jomopans-echo"` (default) / `"randi"` / `"wales-heitmeyer"`<br>• `frequency_hz` (Default: decidecade 10 Hz–31.5 kHz) | `ship_source_spectrum(18, 300, vessel_class="containership")`<br><br>• `ShipTrafficSpectrum` |
| `ShipTrafficSpectrum` | `dataclass` | **Ship source-spectrum result.**<br>• `frequency` [Hz] / `source_psd` [dB re 1 µPa²/Hz at 1 m] / `band_level` [dB re 1 µPa m]<br>• `model` / `vessel_class` / `speed_knots` / `length_m`<br>• `.plot()`: source PSD vs frequency | `res.source_psd, res.band_level` |
| `VESSEL_CLASSES` | `tuple` | **The 13 JOMOPANS-ECHO vessel classes.** | `"bulker" in VESSEL_CLASSES` |
| `normal_modes` | `function` | **Normal-mode transmission loss (range-independent).**<br>• `frequency_hz` / `depths` [m] / `sound_speeds` [m/s]<br>• `source_depth` / `receiver_depth` [m] / `ranges_m` / `density` / `bottom`: `"pressure-release"`\|`"rigid"` / `n_depth_points` | `normal_modes(50, [0,200], [1500,1500], source_depth=50, receiver_depth=100)`<br><br>• `NormalModeResult` |
| `ray_trace` | `function` | **Ray tracing through a sound-speed profile (vectorised).**<br>• `depths` [m] / `sound_speeds` [m/s]<br>• `source_depth` [m] / `launch_angles_deg` / `max_range` [m] / `n_steps` | `ray_trace(z, c, source_depth=1000, launch_angles_deg=[-10,0,10])`<br><br>• `RayTraceResult` |
| `parabolic_equation` | `function` | **Split-step Fourier parabolic-equation TL field.**<br>• `frequency_hz` / `depths` [m] / `sound_speeds` [m/s]<br>• `source_depth` [m] / `max_range` [m] / `range_step` [m] / `n_depth_points` | `parabolic_equation(50, [0,200], [1500,1500], source_depth=50)`<br><br>• `ParabolicEquationResult` |
| `NormalModeResult` | `dataclass` | **Normal-mode result.**<br>• `frequency` / `wavenumbers` [rad/m] / `mode_depths` [m] / `mode_functions`<br>• `ranges` [m] / `transmission_loss` [dB] / `receiver_depth` / `source_depth`<br>• `.plot()`: TL vs range | `res.wavenumbers, res.transmission_loss` |
| `RayTraceResult` | `dataclass` | **Ray-tracing result.**<br>• `launch_angles` [°] / `ranges` [m] / `depths` [m] (both `(n_rays, n_steps)`)<br>• `source_depth` / `water_depth` [m]<br>• `.plot()`: ray paths | `res.ranges, res.depths` |
| `ParabolicEquationResult` | `dataclass` | **Parabolic-equation result.**<br>• `frequency` / `ranges` [m] / `depths` [m]<br>• `transmission_loss` [dB] `(n_depths, n_ranges)` / `source_depth`<br>• `.plot()`: TL field | `res.transmission_loss` |
| `PileStrikeResult` | `dataclass` | **Pile-strike result.**<br>• `single_strike_sel` [dB re 1 µPa²·s]<br>• `peak_spl` / `spl` [dB re 1 µPa]<br>• `pulse_duration` [s]<br>• `pressure` [Pa] / `fs` [Hz]<br>• `.plot()`: waveform + cumulative energy | `res.single_strike_sel, res.peak_spl` |
| `perceived_noisiness` | `function` | **Per-band perceived noisiness (ICAO Annex 16 App. 2), noys.**<br>• `spl`: 24 one-third-octave band levels 50 Hz–10 kHz [dB] | `perceived_noisiness(spl)` |
| `NOY_BANDS` | `array` | **The 24 noy one-third-octave bands [Hz] (ICAO Annex 16 App. 2).**<br>50 Hz to 10 kHz | `NOY_BANDS[0]  # 50.0` |
| `perceived_noise_level` | `function` | **Perceived noise level PNL (ICAO Annex 16), PNdB.**<br>• `spl`: 24 band levels [dB]<br>= 40 + (10/lg2)·lg N | `perceived_noise_level(spl)` |
| `tone_correction` | `function` | **Tone correction C (ICAO Annex 16, slope method), dB.**<br>• `spl`: 24 band levels [dB]<br>1.5 dB threshold, cap 6⅔ dB | `tone_correction(spl)` |
| `effective_perceived_noise_level` | `function` | **EPNL from a spectral time history (ICAO Annex 16), EPNdB.**<br>• `spectra`: (K, 24) band levels [dB]<br>• `dt` (Default: 0.5) [s]<br>• `reference_time` (Default: 10) [s]<br>• `procedure`: 'aeroplane' (tone correction from 80 Hz, Default) / 'helicopter' (from 50 Hz, App. 2 4.3.1)<br>EPNL = PNLTM + D | `res = effective_perceived_noise_level(spectra)`<br><br>• `EPNLResult` |
| `epnl_from_pnlt` | `function` | **EPNL + 10 dB-down limits from a PNLT series (ICAO Annex 16).**<br>• `pnlt`: PNLT(k) [PNdB]<br>• `dt` (Default: 0.5) [s]<br>• `reference_time` (Default: 10) [s] | `epnl, pnltm, kf, kl = epnl_from_pnlt(pnlt)` |
| `EPNLResult` | `dataclass` | **EPNL result.**<br>• `frequencies` [Hz] / `times` [s]<br>• `pnl` / `tone_correction` / `pnlt` [PNdB, dB]<br>• `pnltm` / `duration_correction` / `epnl`<br>• `band_limits` (kF, kL)<br>• `.plot()`: PNL/PNLT time history | `res.epnl, res.pnltm, res.band_limits` |
| `verify_aircraft_noise_system` | `function` | **IEC 61265 measurement-system verification.**<br>• `directional`: {f: {angle: \|Δsens\| dB}} (Table 1)<br>• `frequency_response` / `linearity` / `resolution`<br>filters via `verify_filter_class` | `verify_aircraft_noise_system(directional=meas)` |
| `sae_band_attenuation` | `function` | **One-third-octave-band atmospheric absorption (SAE ARP 5534).**<br>• `frequencies` [Hz] / `path_length` [m]<br>• `temperature` (Default: 25) / `relative_humidity` (Default: 70) / `pressure` (Default: 101.325)<br>δ_B from pure-tone δ_t=α·s (α from ISO 9613-1) | `sae_band_attenuation(freqs, 7620.0)`<br><br>• `AircraftBandAttenuation` |
| `AircraftBandAttenuation` | `dataclass` | **Aircraft band-absorption result.**<br>• `frequency` [Hz] / `band_attenuation` [dB] / `midband_attenuation` [dB] / `coefficient` [dB/m]<br>• `path_length` / `temperature` / `relative_humidity` / `pressure`<br>• `.plot()`: band vs pure-tone mid-band | `res.band_attenuation` |
| `npd_level` | `function` | **NPD event-level interpolation (ECAC Doc 29 §4.2).**<br>• `powers` / `distances` [m] / `levels` [dB] (shape P×D)<br>• `power` / `distance` [m]<br>linear in power (Eq. 4-3), log-linear in distance (Eq. 4-4) | `npd_level(P, D, L, 16000, 1500)` |
| `npd_curve` | `function` | **NPD level over a distance sweep at one power.**<br>• `powers` / `distances` / `levels`<br>• `power` / `query_distances` (Default: log sweep) | `npd_curve(P, D, L, 20000)`<br><br>• `NpdLevelResult` |
| `NpdLevelResult` | `dataclass` | **NPD distance-sweep result.**<br>• `distance` [m] / `level` [dB] / `power`<br>• `table_distances` / `table_levels`<br>• `.plot()`: level vs slant distance | `res.distance, res.level` |
| `lateral_attenuation` | `function` | **Excess lateral attenuation Λ(β,ℓ) (ECAC Doc 29 Eq. 4-18/4-19).**<br>• `elevation_deg` β / `lateral_m` ℓ | `lateral_attenuation(10, 500)` |
| `engine_installation_correction` | `function` | **Engine-install lateral directivity ΔI(φ) (Eq. 4-15/4-16).**<br>• `depression_deg` φ / `mounting`: `"wing"`\|`"fuselage"`\|`"propeller"` | `engine_installation_correction(20, "wing")` |
| `duration_correction` | `function` | **Duration correction ΔV = 10·lg(Vref/Vseg) (Eq. 4-14).**<br>• `reference_speed` / `segment_speed` | `duration_correction(82.3, 90)` |
| `noise_fraction` | `function` | **Finite-segment noise fraction ΔF (Eq. 4-20).**<br>• `q` [m] / `segment_length` λ [m] / `scaled_distance` dλ [m] | `noise_fraction(0, 5000, 100)` |
| `impedance_adjustment` | `function` | **Acoustic-impedance adjustment of NPD data (Eq. 4-6/4-7).**<br>• `temperature` [°C] / `pressure` [kPa]<br>= 10·lg(ρc/409.81); +0.074 dB at ISA | `impedance_adjustment(15, 101.325)` |
| `start_of_roll_directivity` | `function` | **Start-of-roll rearward directivity ΔSOR (Eq. 4-22/4-24/4-25).**<br>• `azimuth_deg` ψ (arccos q/dSOR) / `distance_m` dSOR<br>• `engine`: `"jet"`\|`"turboprop"`; 0 ahead (ψ<90°) | `start_of_roll_directivity(120, 300, "jet")` |
| `event_level` | `function` | **Single-event SEL/LAmax of a flight path at a receiver (Eq. 4-8/4-10/4-11).**<br>• `path` (N,5): x,y,z,power,speed / `observer` (x,y,z)<br>• NPD `powers` / `distances` / `exposure_levels` / `maximum_levels`<br>• `reference_speed` / `mounting` / `metric`: `"exposure"`\|`"maximum"`<br>• `temperature` / `pressure` (impedance) / `ground_roll` mask | `event_level(path, obs, P, D, SEL, LMAX)`<br><br>• `FlyoverResult` |
| `noise_contour` | `function` | **Single-event noise level over a ground grid → contour.**<br>• `path` / NPD tables / `x` / `y` grids [m]<br>• `reference_speed` / `mounting` / `metric` / `temperature` / `pressure` / `ground_roll` mask | `noise_contour(path, P, D, SEL, LMAX, x=gx, y=gy)`<br><br>• `NoiseContourResult` |
| `FlyoverResult` | `dataclass` | **Single-event result.**<br>• `level` [dB] / `metric` / `segment_levels` [dB] / `observer`<br>• `.plot()`: per-segment contributions | `res.level` |
| `NoiseContourResult` | `dataclass` | **Ground-grid contour result.**<br>• `x` / `y` [m] / `level` [dB] (Ny×Nx) / `metric`<br>• `.plot()`: filled contours | `res.level` |
| `load_anp_database` | `function` | **Load an EASA ANP fleet database (NPD curves + default profiles).**<br>• `path`: CSV export directory, or `None` for the bundled curated subset | `db = load_anp_database()`<br><br>• `AnpDatabase` |
| `AnpDatabase` | `class` | **Parsed ANP database wiring real aircraft into the Doc 29 chain.**<br>• `.aircraft(id)` / `.aircraft_ids` / `.npd_curves(id, op, metric)` / `.profile(id, op, profile_id=)`<br>• `.event_level(id, obs, op)` / `.noise_contour(id, op, x=, y=)` | `db.noise_contour("747100", "departure", x=gx, y=gy)`<br><br>• `AnpAircraft` |
| `AnpAircraft` | `dataclass` | **One ANP aircraft type: metadata + NPD/profile access + Doc 29 wiring.**<br>• `aircraft_id` / `description` / `engine_type` / `num_engines` / `weight_class` / `mounting` / `npd_id`<br>• `.npd_curves(op, metric)` / `.profile(op)` / `.event_level(obs, op)` / `.noise_contour(op, x=, y=)` | `db.aircraft("747100").mounting` |
| `AnpNpdCurves` | `dataclass` | **ANP NPD curves for one aircraft, metric and operation.**<br>• `powers` / `distances` [m] / `levels` [dB] (P×D) / `metric` / `operation`<br>• `.level(power, distance)` / `.plot()`: level vs slant distance | `db.npd_curves("747100", "departure", "SEL")` |
| `AnpProfile` | `dataclass` | **ANP default fixed-point trajectory as a Doc 29 flight path.**<br>• `path` (N,5): x,y,z,power,speed [SI] / `ground_roll` / `landing_roll` masks<br>• `.plot()`: altitude vs along-track distance | `db.profile("747100", "departure").path` |
| `RotorcraftHemisphere` | `dataclass` | **Rotorcraft noise hemisphere source (ECAC Doc 32).**<br>• `frequencies` [Hz] / `azimuth` φ / `polar` θ [°] / `levels` [dB] (A×P×F) / `distance` (60 m)<br>• `.plot()`: fore-aft directivity / `.mirrored()`: φ → −φ (Eq. 2) | `h.levels` |
| `hemisphere_source_level` | `function` | **Interpolated source level L(fc,φ,θ) (Eq. 13-15).**<br>• `hemisphere` / `azimuth_deg` φ / `polar_deg` θ<br>• energy-bilinear + nearest-bin fill | `hemisphere_source_level(h, 0, 90)` |
| `spherical_spreading_adjustment` | `function` | **ΔLs = −20·lg(r/60) (ECAC Doc 32 Eq. 24).**<br>• `distance` r [m] | `spherical_spreading_adjustment(600)` |
| `atmospheric_adjustment` | `function` | **ΔLa = −α(f)·(r−60) (Eq. 26/27; ISO 9613-1 α; Table 4).**<br>• `frequencies` [Hz] / `distance` r [m]<br>• `temperature` / `relative_humidity` / `pressure` | `atmospheric_adjustment(f, 1060)` |
| `ground_effect_adjustment` | `function` | **ΔLg over an impedance plane (Chien-Soroka, Eq. 28-35).**<br>• `frequencies` / `source_height` / `receiver_height` / `horizontal_distance`<br>• `flow_resistivity` σ or CNOSSOS class `"A"`-`"H"` | `ground_effect_adjustment(f, 150, 1.5, 500)` |
| `flight_condition_weights` | `function` | **Hemisphere blend weights for a flight condition (NORAH2 Eq. 3-10).**<br>• database `airspeeds` / `path_angles` [°] + query `airspeed` / `path_angle`<br>• Delaunay triangle blend inside the hull, nearest outside<br>• `scaling_factor` Ffc (2) / `triangles` lookup table | `flight_condition_weights(V, G, 60, 2.5)` |
| `interpolated_source_level` | `function` | **Source level between hemispheres (Eq. 8/10 over Eq. 13).**<br>• `hemispheres` + database conditions + query condition<br>• `azimuth_deg` φ / `polar_deg` θ | `interpolated_source_level(hs, V, G, 60, 2.5, 0, 90)` |
| `flight_path_kinematics` | `function` | **Track kinematics by central differences (Eq. 16-21).**<br>• `times` [s] / `positions` (N,3) [m]<br>• Vg, VA, heading, curvature, bank Φ = atan(K·Vg²/g), path angle γ | `flight_path_kinematics(t, xyz)`<br><br>• `FlightPathKinematics` |
| `FlightPathKinematics` | `dataclass` | **Track kinematics result.**<br>• `ground_speed` / `airspeed` [m/s] / `heading` / `bank_angle` / `path_angle` [°] / `curvature` [rad/m]<br>• `.plot()`: speed and angle profiles | `kin.airspeed` |
| `rotorcraft_event_level` | `function` | **Single-event time history and metrics at a receiver (Doc 32 §6.1).**<br>• `hemispheres` + conditions / `times` / `positions` / `receiver` (x,y)<br>• `receiver_height` / `ground_elevation` / `flow_resistivity`<br>• per-point `airspeed` / `path_angle` / `heading` / `bank_angle` / `level_offset`<br>• `atmospheric_method`: `"iso9613"`\|`"sae"` / `terrain` (x, y, z model) + `terrain_resolution` | `rotorcraft_event_level(hs, V, G, t, xyz, (x, y))`<br><br>• `RotorcraftEventResult` |
| `RotorcraftEventResult` | `dataclass` | **Single-event result.**<br>• `times` (recorded) / `band_levels` / `a_levels` [dB(A)]<br>• `la_max` / `sel` / `sel_10db` / `pnlt` / `pnltm` / `epnl`<br>• `.plot()`: LA(t) history with the 10 dB-down window | `res.sel, res.epnl` |
| `rotorcraft_noise_contour` | `function` | **Single-event SEL/LASmax over a ground grid (Doc 32 §6.3).**<br>• event inputs + `x` / `y` grids [m]<br>• `metric`: `"exposure"`\|`"maximum"`<br>• per-receiver `flow_resistivity` / `ground_elevation` arrays; `terrain` model | `rotorcraft_noise_contour(hs, V, G, t, xyz, x=gx, y=gy)`<br><br>• `RotorcraftNoiseContourResult` |
| `RotorcraftNoiseContourResult` | `dataclass` | **Rotorcraft ground-grid contour result.**<br>• `x` / `y` [m] / `level` [dB(A)] (Ny×Nx) / `metric`<br>• `.plot()`: filled contours | `res.level` |
| `mean_ground_plane` | `function` | **Mean ground plane of a terrain section (NORAH2 Eq. 36-40).**<br>• `distances` / `heights` [m] (polyline)<br>• continuous least squares in closed form | `mean_ground_plane(d, z)`<br><br>• `MeanGroundPlaneResult` |
| `MeanGroundPlaneResult` | `dataclass` | **Fitted plane.**<br>• `slope` a / `intercept` b [m]<br>• `.height(d)` / `.equivalent_height(d, z)` (orthogonal)<br>• `.plot()`: section + plane | `res.slope` |
| `mean_flow_resistivity` | `function` | **Log-mean flow resistivity along a path (Eq. 41).**<br>• `lengths` [m] / `resistivities` [Pa·s/m²] per segment | `mean_flow_resistivity(d, sig)` |
| `diffraction_attenuation` | `function` | **Pure diffraction ΔLd per band (Eq. 42-44).**<br>• `path_difference` δ [m] (negative allowed) / `edge_height` h0 [m]<br>• `edge_span` e (C″, multiple edges) / `capped` (25 dB) | `diffraction_attenuation(f, 0.5, edge_height=10)` |
| `terrain_screening_adjustment` | `function` | **Ground + screening over a vertical section (§A.4.4-A.4.5).**<br>• `source` / `receiver` (d, z) [m] / `distances` / `heights` (terrain)<br>• `flow_resistivity`: value, class or per segment (Eq. 41)<br>• clear path → mean-plane ground effect; blocked → rubber band + Eq. 45-47 | `terrain_screening_adjustment(f, S, R, d, z)`<br><br>• `TerrainScreeningResult` |
| `TerrainScreeningResult` | `dataclass` | **Section result.**<br>• `adjustment` [dB] (replaces the flat ΔLg) / `screened` / `path_difference` δ [m] / `diffraction_points`<br>• `.plot()`: section geometry | `res.adjustment` |
| `slant_distance` | `function` | **Rotor-centre-to-microphone slant distance (IEC 61400-11).**<br>• `hub_height` H [m]<br>• `rotor_diameter` D [m]<br>• `rotor_axis` (Default: "horizontal" → R0 = H+D/2, Formula 1; "vertical" → R0 = H+D, Formula 2)<br>= √(H² + R0²) | `slant_distance(80.0, 100.0)` |
| `apparent_sound_power_level` | `function` | **Apparent sound power level (IEC 61400-11 Formula 26), dB re 1 pW.**<br>• `band_levels`: background-corrected A-weighted band SPL [dB]<br>• `r1`: slant distance [m]<br>= L_p − 6 + 10·lg(4π R1²/S0) | `apparent_sound_power_level(levels, r1)` |
| `wind_turbine_tonality` | `function` | **Tonal audibility from a narrowband spectrum (IEC 61400-11), with the 9.5.2 possible-tone screening; tone lines and La anchor to the highest classified line (9.5.3/9.5.4).**<br>• `levels` [dB] / `frequencies` [Hz]: 1-D, equal length (≥ 3), strictly increasing and uniformly spaced (narrowband), covering the whole critical band<br>• `tone_frequency`: optional [Hz] (≥ 20 Hz) | `res = wind_turbine_tonality(levels, freqs)`<br><br>• `WindTurbineTonalityResult` |
| `WindTurbineTonalityResult` | `dataclass` | **Wind-turbine tonality result.**<br>• `tone_frequency` [Hz] / `critical_bandwidth` [Hz]<br>• `tone_level` / `masking_level` / `tonality` [dB]<br>• `audibility_criterion` / `tonal_audibility` [dB]<br>• `is_audible` / `has_identified_tone` (False → exclude from 9.5.1 bin averaging)<br>• `.plot()`: spectrum + critical band + masking | `res.tonal_audibility, res.is_audible` |
| `WindTurbineNoiseWarning` | `warning class` | **IEC 61400-11 advisory.**<br>Emitted when the tonality inputs leave the standard's stated domain of validity | `warnings.simplefilter('error', WindTurbineNoiseWarning)` |
| `noise_criterion` | `function` | **NC rating, clause 5.2.2 two-step (ANSI/ASA S12.2-2019).**<br>• `levels`: octave-band SPL [dB] (the 10 bands 16 Hz–8 kHz without `frequencies`)<br>• `frequencies`: optional band centres [Hz] (subset allowed) | `nc = noise_criterion(levels)`<br><br>• `NCResult`; NC-(SIL) designation, tangency when exceeded |
| `room_criterion` | `function` | **RC Mark II rating (ANSI/ASA S12.2-2019 Annex D).**<br>• `levels`: octave-band SPL [dB]<br>• `frequencies`: optional band centres [Hz] | `rc = room_criterion(levels)`<br><br>• `RCResult`; LMF average + 'N'/'R'/'H'/'RH' tag |
| `nc_curve` | `function` | **NC curve levels (Table 1).**<br>• `index`: NC designation 15–70 (intermediate values interpolated band by band) | `nc_curve(35)  # [82, 71, 60, ...]`<br><br>• 10 band levels [dB], 16 Hz–8 kHz |
| `rc_curve` | `function` | **RC Mark II curve (Table D.1).**<br>• `index`: RC designation (value at 1000 Hz) | `rc_curve(35)`<br><br>• −5 dB/octave line, 10 bands [dB] |
| `NCResult` | `dataclass` | **NC rating result.**<br>• `rating`: NC designation (NaN outside NC-15…NC-70, see `out_of_range`)<br>• `sil`, `tangency_rating`, `method`, `label`<br>• `governing_frequency`: tangency band [Hz]<br>• `frequencies` [Hz], `levels` [dB]<br>• `.plot()` | `nc.rating, nc.label` |
| `RCResult` | `dataclass` | **RC Mark II result.**<br>• `rating`: LMF rounded [dB], int<br>• `lmf`: 500/1000/2000 Hz average [dB]<br>• `classification`: 'N'/'R'/'H'/'RH'<br>• `reference_curve` [dB], `frequencies` [Hz], `levels` [dB]<br>• `.plot()` | `rc.rating, rc.classification` |
| `age_threshold` | `function` | **Age-related hearing threshold distribution (ISO 7029:2017).**<br>• `age`: years (≥ 18)<br>• `sex`: 'male'/'female' (Default: 'male')<br>• `fractile`: population fractile in (0, 1) (Default: 0.5)<br>• `frequencies`: subset of the 11 audiometric frequencies [Hz] (Default: None → 125–8000 Hz) | `at = age_threshold(60, 'male')`<br><br>• `AgeThresholdResult`; median at 4 kHz = 20.2 dB |
| `reference_threshold` | `function` | **Reference threshold of hearing, 0 dB HL (ISO 389-7:2005 Table 1).**<br>• `field`: 'free-field' or 'diffuse-field' (Default: 'free-field')<br>• `frequencies`: optional subset [Hz] | `t = reference_threshold()`<br><br>• dB SPL per audiometric frequency (1 kHz → 2.4) |
| `AgeThresholdResult` | `dataclass` | **Age threshold distribution.**<br>• `age`, `sex`, `fractile`<br>• `frequencies` [Hz]<br>• `median`: deviation from age 18 [dB]<br>• `spread_upper` / `spread_lower`: half-Gaussian su/sl [dB]<br>• `threshold`: value at `fractile` [dB]<br>• `.plot()` | `at.median, at.threshold` |
| `nipts` | `function` | **Noise-induced permanent threshold shift (ISO 1999:2013 §6.3).**<br>• `l_ex`: LEX,8h [dB]<br>• `years`: exposure duration (10–40 established; 1–10 extrapolated by Formula 3)<br>• `fractile`: (0, 1) (Default: 0.5)<br>• `frequencies`: subset [Hz] (Default: None → 500–6000 Hz) | `n = nipts(95.0, 20.0)`<br><br>• `NiptsResult`; N50 at 4 kHz = 23.0 dB |
| `htlan` | `function` | **Threshold associated with age and noise, H′ = H + N − HN/120 (ISO 1999 Formula 1).**<br>• `age`: years (≥ 18), `sex`: 'male'/'female'<br>• `l_ex`: LEX,8h [dB], `years`<br>• `fractile`: (0, 1) applied to both components (Default: 0.5)<br>• `frequencies` | `h = htlan(60, 'male', 95.0, 20.0)`<br><br>• `HtlanResult`; H′ at 4 kHz = 39.3 dB |
| `NiptsResult` | `dataclass` | **NIPTS distribution.**<br>• `l_ex`, `years`, `fractile`<br>• `frequencies` [Hz]<br>• `median`: N50 (Formula 2/3) [dB]<br>• `value`: NIPTS at `fractile` (Formula 4/5) [dB]<br>• `spread_upper` / `spread_lower`: du/dl [dB]<br>• `.plot()` | `n.median, n.value` |
| `HtlanResult` | `dataclass` | **Combined HTLAN result.**<br>• `age`, `sex`, `l_ex`, `years`, `fractile`<br>• `frequencies` [Hz]<br>• `htla`: age component H [dB]<br>• `nipts`: noise component N [dB]<br>• `threshold`: combined H′ [dB]<br>• `.plot()` | `h.htla, h.nipts, h.threshold` |
| `predicted_prominence` | `function` | **Predicted prominence P of an impulse (NT ACOU 112 Formula 1).**<br>• `onset_rate`: slope of the A/F level onset [dB/s] (> 0; qualifies above 10 dB/s)<br>• `level_difference`: level rise over the onset [dB] (> 0) | `predicted_prominence(50.0, 20.0)  # 7.7`<br><br>• P = 3 lg(rate) + 2 lg(diff) |
| `impulse_adjustment` | `function` | **LAeq adjustment KI (Formula 2).**<br>• `prominence`: P | `impulse_adjustment(7.7)  # 4.86`<br><br>• 1.8(P − 5) dB for P > 5, else 0 |
| `impulse_prominence` | `function` | **Governing prominence and adjustment of a set of impulses (clauses 7–8); only onset rates > 10 dB/s qualify (clause 4.5).**<br>• `onset_rates` [dB/s]<br>• `level_differences` [dB] | `r = impulse_prominence([50, 120], [20, 15])`<br><br>• `ImpulseProminenceResult` (P = 8.59, KI = 6.46 dB) |
| `rating_level` | `function` | **Rating level LAr,T over a reference time (clause 8 Note 1).**<br>• `laeq`: LAeq,N per sub-interval [dB]<br>• `adjustment`: KI,N per sub-interval [dB]<br>• `durations`, `reference_time`: same time unit | `rating_level([55, 60], [0, 4.9], [20, 10], 30)  # 60.9` |
| `ImpulseProminenceResult` | `dataclass` | **Impulse-prominence verdict.**<br>• `onset_rates` [dB/s], `level_differences` [dB]<br>• `per_impulse`: P of each impulse<br>• `qualifies`: onset rate > 10 dB/s mask (clause 4.5)<br>• `prominence`: governing (highest qualifying) P<br>• `adjustment`: KI of the governing qualifying impulse [dB] (0 when none qualifies)<br>• `.plot()` | `r.prominence, r.adjustment` |
| `ImpulseProminenceWarning` | `warning class` | **NT ACOU 112 advisory.**<br>Emitted when a supplied level rise does not qualify as an impulse (clause 4.5) | `warnings.simplefilter('error', ImpulseProminenceWarning)` |
| `impulsive_sound_adjustment` | `function` | **Objective impulsive-sound adjustment from a calibrated signal (ISO/PAS 1996-3:2022).**<br>• `signal`: sound pressure [Pa], `fs` [Hz]<br>• `dt`: LpAF sampling interval [s] (10–25 ms; Default 0.02)<br>• `onset_rate_method`: 'least_squares' or 'upper_half' pass-by variant<br>• `calibration_offset` [dB], `laeq` [dB] | `r = impulsive_sound_adjustment(sig, fs)`<br><br>• `ImpulsiveSoundResult` (P, KI, category) |
| `sound_pressure_level_history` | `function` | **A-weighted, F time-weighted level history LpAF (ISO/PAS 1996-3 Clause 4).**<br>• `signal` [Pa], `fs` [Hz]<br>• `dt`: sampling interval [s] (10–25 ms; Default 0.02)<br>• `reference_pressure` [Pa], `calibration_offset` [dB] | `t, lpaf = sound_pressure_level_history(sig, fs)` |
| `detect_onsets` | `function` | **Detect LpAF onsets: start/end points, level difference and least-squares onset rate (ISO/PAS 1996-3 Clause 4, procedures a–d).**<br>• `levels`: LpAF [dB], `dt` [s]<br>• `onset_rate_method`: 'least_squares' / 'upper_half' | `onsets = detect_onsets(lpaf, 0.02)` |
| `ImpulsiveSoundResult` | `dataclass` | **Objective impulsive-sound verdict (ISO/PAS 1996-3).**<br>• `times` [s], `levels`: LpAF [dB], `dt` [s]<br>• `onsets`: detected `ImpulseOnset`s<br>• `prominence`: governing P, `adjustment`: KI [dB]<br>• `category`: 'not impulsive' / 'regular impulsive' / 'highly impulsive'<br>• `laeq`, `adjusted_laeq` [dB]<br>• `.governing_onset`, `.plot()` | `r.adjustment, r.category` |
| `ImpulseOnset` | `dataclass` | **A single detected LpAF onset (ISO/PAS 1996-3 Clause 3).**<br>• `index_start`/`index_end`, `time_start`/`time_end` [s]<br>• `level_start`/`level_end` [dB]<br>• `level_difference`: LD = Le − Ls [dB]<br>• `onset_rate`: OR [dB/s]<br>• `prominence`: P, `qualifies`: OR > 10 dB/s | `o.onset_rate, o.level_difference` |
| `ImpulsiveSoundWarning` | `warning class` | **ISO/PAS 1996-3 advisory.**<br>Emitted when no onset with a gradient above 10 dB/s is found (adjustment is 0 dB) | `warnings.simplefilter('error', ImpulsiveSoundWarning)` |
| `multiple_shock_assessment` | `function` | **Full multiple-shock assessment from seat acceleration (ISO 2631-5:2018).**<br>• `acceleration`: vertical seat az(t) [m/s²], `fs` [Hz]<br>• `start_age`: years, `years`: exposure years, `days_per_year`<br>• `exposure_time` / `measurement_time`: scale Dz to a daily dose (Default: None)<br>• `sex`: 'male'/'female' (Default: 'male')<br>• `mz`: MPa per m/s² (Default: sex-specific) | `res = multiple_shock_assessment(az, fs, start_age=25, years=30, days_per_year=220)`<br><br>• `MultipleShockResult` |
| `seat_to_spine_transfer` | `function` | **Seat-to-spine transfer function H(ω) (clause 5.2 Formula 1).**<br>• `frequencies` [Hz] | `H = seat_to_spine_transfer(freqs)`<br><br>• Complex response; unity at 0 Hz |
| `spinal_response` | `function` | **Vertical spinal response Az(t) (Formula 2).**<br>• `acceleration`: seat az(t) [m/s²]<br>• `fs` [Hz] | `Az = spinal_response(az, fs)`<br><br>• Same length as the input [m/s²] |
| `response_peaks` | `function` | **Positive response peaks Az,i (clause 5.3).**<br>• `response`: Az(t) | `peaks = response_peaks(Az)`<br><br>• Maxima between zero crossings [m/s²] |
| `dose_from_peaks` | `function` | **Acceleration dose Dz from peaks (Formula 3).**<br>• `peaks`: Az,i [m/s²] | `dz = dose_from_peaks(peaks)`<br><br>• Dz = 1.07 (Σ Az,i⁶)^(1/6) [m/s²] |
| `acceleration_dose` | `function` | **Acceleration dose Dz from a time history (Formulas 2 + 3).**<br>• `acceleration`: seat az(t) [m/s²], `fs` [Hz] | `dz = acceleration_dose(az, fs)`<br><br>• Dz [m/s²] |
| `daily_dose` | `function` | **Daily dose Dzd (Formula 4).**<br>• `dose`: Dz [m/s²]<br>• `exposure_time` td / `measurement_time` tm: same unit | `daily_dose(4.0, 8.0, 2.0)  # 5.04`<br><br>• Dz (td/tm)^(1/6) [m/s²] |
| `daily_dose_multi` | `function` | **Daily dose from several exposure conditions (Formula 5).**<br>• `doses`: Dz,j [m/s²]<br>• `exposure_times` / `measurement_times`: per condition | `dzd = daily_dose_multi(dz, td, tm)`<br><br>• [Σ Dz,j⁶ (td,j/tm,j)]^(1/6) [m/s²] |
| `compression_dose` | `function` | **Daily compressive stress Sd (Annex C Formula C.1).**<br>• `daily_dose_value`: Dzd [m/s²]<br>• `mz`: MPa per m/s² (Default: 0.029, 82 kg male) | `compression_dose(5.0)  # 0.145`<br><br>• Sd = mz·Dzd [MPa] |
| `static_stress` | `function` | **Static compressive stress Sstat = mz·9.81 (Annex C).**<br>• `mz` (Default: 0.029) | `static_stress()  # 0.284`<br><br>• [MPa] |
| `ultimate_strength` | `function` | **Ultimate lumbar strength Su at an age (Formula C.4).**<br>• `age`: years<br>• `sex`: 'male'/'female' (Default: 'male') | `ultimate_strength(45.0)  # 4.41`<br><br>• 6.75 − Sage·age [MPa] |
| `injury_risk` | `function` | **Cumulative injury stress variable R (Formula C.3).**<br>• `daily_compression`: Sd [MPa]<br>• `start_age`, `years`, `days_per_year`<br>• `sex`, `mz` | `R = injury_risk(0.5, start_age=25, years=30, days_per_year=220)  # 0.509` |
| `injury_probability` | `function` | **Probability of lumbar injury P(R) (Formula C.5, Weibull).**<br>• `risk`: R<br>• `sex` (Default: 'male') | `injury_probability(0.509)  # 0.0389`<br><br>• 1 − exp(−(R/α)^β) in 0–1 |
| `MultipleShockResult` | `dataclass` | **Multiple-shock health assessment.**<br>• `acceleration_dose` Dz, `daily_dose` Dzd [m/s²]<br>• `compression_dose` Sd [MPa]<br>• `risk` R, `probability` P(R)<br>• `sex`, `start_age`, `years`, `days_per_year`<br>• `peaks` [m/s²], `risk_thresholds`: R at 10/50/90 % (Table C.2)<br>• `.plot()` | `res.risk, res.probability` |
| `object_fraction` | `function` | **Object fraction ψ of an enclosed space (EN 12354-6 Formula 3).**<br>• `object_volumes` [m³]<br>• `volume`: empty space V [m³] | `object_fraction([12.0, 8.0], 500.0)  # 0.04` |
| `hard_object_absorption` | `function` | **Equivalent absorption area of a hard object (Formula 4).**<br>• `object_volume`: Vobj [m³] | `hard_object_absorption(8.0)  # 4.0`<br><br>• Aobj = Vobj^(2/3) [m²] |
| `air_absorption_area` | `function` | **Equivalent absorption area of the air (Formula 2).**<br>• `m`: power attenuation of air [Np/m]<br>• `volume` V [m³]<br>• `object_fraction` ψ (Default: 0.0) | `air_absorption_area(0.001, 500.0)  # 2.0`<br><br>• Aair = 4mV(1 − ψ) [m²] |
| `equivalent_absorption_area` | `function` | **Total equivalent sound absorption area (Formula 1).**<br>• `surfaces`: sequence of (area, absorption_coefficient) pairs (coefficient scalar or per band)<br>• `objects`: object areas Aobj [m²] (Default: ())<br>• `air_area`: Aair [m²] (Default: 0.0) | `A = equivalent_absorption_area([(100.0, 0.03), (50.0, 0.6)], objects=[4.0], air_area=2.0)  # 39.0` |
| `reverberation_time` | `function` | **Reverberation time from the absorption area (Formula 5).**<br>• `absorption_area` A [m²]<br>• `volume` V [m³]<br>• `object_fraction` ψ (Default: 0.0)<br>• `speed_of_sound` c0 [m/s] (Default: 345.6 → factor 0.16) | `reverberation_time(39.0, 500.0)  # 2.05`<br><br>• T = 55.3/c0 · V(1 − ψ)/A [s] |
| `enclosed_space_reverberation` | `function` | **Predicted A and T per octave band (EN 12354-6 Clause 4).**<br>• `surfaces`: (area, per-band α) pairs<br>• `volume` V [m³]<br>• `objects` [m²], `object_fraction` ψ<br>• `air_condition`: air-attenuation key, e.g. '20C_50-70' (Default: None → neglect air)<br>• `frequencies` [Hz] (Default: 125–8000 Hz octaves)<br>• `speed_of_sound` [m/s] | `res = enclosed_space_reverberation(surfaces, 500.0, air_condition="20C_50-70")`<br><br>• `ReverberationResult` |
| `ReverberationResult` | `dataclass` | **Enclosed-space absorption result.**<br>• `frequencies` [Hz]<br>• `absorption_area`: A per band [m²]<br>• `reverberation_time`: T per band [s]<br>• `volume` [m³], `object_fraction`<br>• `.plot()` | `res.absorption_area, res.reverberation_time` |
| `sabine_reverberation_time` / `eyring_reverberation_time` / `millington_sette_reverberation_time` | `function` | **Statistical RT prediction (T = k·V/(term + 4mV), k = 24 ln10 / c0).**<br>• `volume` V [m³]<br>• `surfaces`: (area, α) pairs (α scalar or per band)<br>• `air_attenuation`: m [Np/m] (Default: 0.0; see `air_attenuation_m`)<br>• `speed_of_sound` (Default: 343 → k = 0.161)<br>Term: Sabine ΣSᵢαᵢ · Eyring −S·ln(1−ᾱ) · Millington −ΣSᵢln(1−αᵢ) | `eyring_reverberation_time(120.0, [(40,0.2),(40,0.2),(24,0.2),(24,0.2),(15,0.2),(15,0.2)])  # 0.548 s` |
| `fitzroy_reverberation_time` / `arau_puchades_reverberation_time` | `function` | **Anisotropic RT prediction for a shoebox room.**<br>• `dimensions`: (Lx, Ly, Lz) [m]<br>• `absorptions`: mean α of the (x, y, z) wall pairs (scalar or per band)<br>• `air_attenuation` [Np/m], `speed_of_sound`<br>Fitzroy = area-weighted arithmetic mean of the three axial Eyring times; Arau-Puchades = geometric mean (Acustica 65, 1988) | `arau_puchades_reverberation_time((8,5,3), (0.5,0.1,0.1))  # 0.812 s` |
| `mean_absorption` | `function` | **Area-weighted mean absorption ᾱ = ΣSᵢαᵢ / ΣSᵢ.**<br>• `surfaces`: (area, α) pairs | `mean_absorption([(90.0, 0.1), (10.0, 0.9)])  # 0.18` |
| `reverberation_time_models` | `function` | **All five RT models for a shoebox room, per band.**<br>• `dimensions` (Lx, Ly, Lz) [m]<br>• `absorptions`: (αx, αy, αz) wall-pair means (scalar or per band)<br>• `air_attenuation` [Np/m], `frequencies` [Hz], `speed_of_sound` | `res = reverberation_time_models((10,7,3.5), (ax, ay, az), frequencies=f)`<br><br>• `ReverberationModelResult` |
| `ReverberationModelResult` | `dataclass` | **Five-model RT comparison.**<br>• `frequencies` [Hz]<br>• `sabine`/`eyring`/`millington_sette`/`fitzroy`/`arau_puchades`: T per band [s]<br>• `volume` [m³], `surface_area` [m²]<br>• `.models`: dict keyed by name<br>• `.plot()` | `res.arau_puchades, res.models['Fitzroy']` |
| `apparent_dynamic_stiffness` | `function` | **Apparent dynamic stiffness s't (EN 29052-1 Formula 4).**<br>• `resonant_frequency` fr [Hz] (scalar/array)<br>• `total_mass_per_area` m't [kg/m²]<br>s't = 4π²·m't·fr² [N/m³] | `apparent_dynamic_stiffness(25.0, 200.0)  # 4.935e6 N/m³` |
| `enclosed_gas_stiffness` | `function` | **Enclosed-gas dynamic stiffness s'a (EN 29052-1 Formula 7).**<br>• `thickness` d [m]<br>• `porosity` ε (0-1)<br>• `atmospheric_pressure` p₀ [Pa] (Default: 1e5 = 0,1 MPa)<br>s'a = p₀/(d·ε); NOTE ≈ 111/d MN/m³ (d in mm) | `enclosed_gas_stiffness(0.020, 0.9)  # 5.56e6 N/m³` |
| `installed_dynamic_stiffness` | `function` | **Installed s' by airflow resistivity (EN 29052-1 clause 8.2).**<br>• `apparent_stiffness` s't [N/m³]<br>• `airflow_resistivity` r [kPa·s/m²]<br>• `gas_stiffness` s'a [N/m³]<br>r≥100 → s't · 10≤r<100 → s't+s'a · r<10 → s't if s'a negligible else nan (warns) | `installed_dynamic_stiffness(20e6, 50.0, gas_stiffness=3e6)  # 23e6` |
| `natural_frequency` | `function` | **Floating-floor natural frequency f0 (EN 29052-1 Formula 2).**<br>• `dynamic_stiffness` s' [N/m³] (scalar/array)<br>• `mass_per_area` m' [kg/m²]<br>f0 = (1/2π)√(s'/m') | `natural_frequency(10e6, 120.0)  # 45.9 Hz` |
| `floating_floor_resonance` | `function` | **Full EN 29052-1 chain → result.**<br>• `resonant_frequency`, `total_mass_per_area`, `floor_mass_per_area`<br>• `airflow_resistivity` (Default: inf), `thickness`, `porosity`, `atmospheric_pressure` | `res = floating_floor_resonance(25.0, 200.0, 120.0)`<br><br>• `DynamicStiffnessResult` |
| `DynamicStiffnessResult` | `dataclass` | **Dynamic-stiffness measurement result.**<br>• `apparent_stiffness`/`gas_stiffness`/`dynamic_stiffness` [N/m³]<br>• `resonant_frequency` [Hz], `floor_mass_per_area` [kg/m²]<br>• `natural_frequency` [Hz]<br>• `.plot()` | `res.dynamic_stiffness, res.natural_frequency` |
| `DynamicStiffnessWarning` | `warning class` | **EN 29052-1 advisory.**<br>Emitted when the enclosed-gas term makes s′ unresolvable (clause 8.2) | `warnings.simplefilter('error', DynamicStiffnessWarning)` |
| `convert_frf` | `function` | **Convert between any two FRFs (ISO 7626-1 Table 1).**<br>• `value`: complex FRF value(s) of kind *source*<br>• `frequency` f [Hz] (scalar/array, broadcast)<br>• `source`/`target`: `receptance`/`mobility`/`accelerance`/`dynamic_stiffness`/`impedance`/`apparent_mass`<br>Pivots through the receptance H | `convert_frf(2e-3, 80.0, "mobility", "impedance")  # 500 N.s/m` |
| `FRF_UNITS` | `mapping` | **SI unit per FRF form (ISO 7626-1 Table 1, read-only).**<br>Keyed by `'receptance'`, `'mobility'`, `'accelerance'`, ... | `FRF_UNITS['mobility']  # 'm/(N·s)'` |
| `sdof_receptance` / `sdof_mobility` / `sdof_accelerance` | `function` | **Closed-form SDOF resonator FRFs (ISO 7626-1 Table 1 / 3.1.2 definitions).**<br>• `frequency` f [Hz] (scalar/array)<br>• `mass` m [kg], `stiffness` k [N/m], `damping` c [N·s/m]<br>H = 1/(k − ω²m + jωc); Y = jωH; A = −ω²H | `sdof_mobility(10.07, 2.0, 8000.0, 5.0)  # |Y|=1/c on resonance` |
| `resonance_frequency` | `function` | **Undamped SDOF natural frequency (closed form).**<br>• `mass` m [kg], `stiffness` k [N/m]<br>f0 = (1/2π)√(k/m) | `resonance_frequency(2.0, 8000.0)  # 10.07 Hz` |
| `rigid_mass_calibration_check` | `function` | **Operational rigid-mass calibration (ISO 7626-2 7.5.2).**<br>• `frf`: measured FRF (complex or magnitude)<br>• `frequencies` [Hz], `mass` m [kg]<br>• `quantity`: 'accelerance' (\|A\| = 1/m) / 'mobility' (\|Y\| = 1/(ωm))<br>• `tolerance` (Default: 0.05 = ±5 %) | `res = rigid_mass_calibration_check(frf, f, 10.0)`<br><br>• `RigidMassCalibrationResult` |
| `RigidMassCalibrationResult` | `dataclass` | **Rigid-mass calibration verdict (ISO 7626-2 7.5.2).**<br>• `frequencies` [Hz], `measured`/`expected`/`deviation` per band<br>• `within_tolerance`: per-band flags, `passed`: overall<br>• `mass` [kg], `quantity`, `tolerance` | `res.passed, res.deviation` |
| `random_error_percent` | `function` | **Normalized random error of an averaged FRF (ISO 7626-2 Annex A).**<br>• `coherence`: γ² per frequency, in (0, 1]<br>• `n_averages`: n ≥ 1<br>ε = √((1−γ²)/(2nγ²)) [%]; 8.1.3 requires < 5 % at resonances | `random_error_percent(0.8, 75)  # 4.08 %` |
| `sdof_mobility_result` | `function` | **SDOF driving-point mobility → result.**<br>• `frequency` f [Hz] (array)<br>• `mass`, `stiffness`, `damping` | `res = sdof_mobility_result(f, 2.0, 8000.0, 5.0)`<br><br>• `MobilityResult` |
| `MobilityResult` | `dataclass` | **Mobility FRF over frequency (ISO 7626-1).**<br>• `frequencies` [Hz], `mobility` Y [m/(N·s)]<br>• `driving_point`: bool (i = j)<br>• `.magnitude` / `.phase`<br>• `.to(target)`: any Table-1 kind<br>• `.plot()`: |Y(f)| with the resonance marked | `res.magnitude, res.to("impedance")` |
| `transfer_stiffness_level` / `loss_factor` | `function` | **Transfer-stiffness level & loss factor (ISO 10846-2/-3 3.17, -1 3.8).**<br>• `transfer_stiffness_level(k, reference=1.0)`: Lₖ = 20 lg(\|k\|/k₀) [dB re 1 N/m]<br>• `loss_factor(k)`: η = Im(k)/Re(k) | `transfer_stiffness_level(1e6)  # 120 dB` |
| `transfer_stiffness_direct` | `function` | **Direct method (ISO 10846-2).**<br>• `blocking_force` F₂,b [N]<br>• `input_displacement` u₁ [m]<br>k₂,₁ = F₂,b/u₁ | `transfer_stiffness_direct(5.0, 1e-6)  # 5e6 N/m` |
| `transfer_stiffness_indirect` | `function` | **Indirect method (ISO 10846-3 Formula 1).**<br>• `frequency` f [Hz] (scalar/array)<br>• `transmissibility` T = u₂/u₁ (complex)<br>• `blocking_mass` m₂ [kg]<br>• `flange_mass` m_f [kg] (Default: 0.0)<br>k₂,₁ = −(2πf)²(m₂+m_f)T; warns (`PhonometryWarning`) where \|T\| > 0.1 (Inequality 2: ΔL1,2 < 20 dB) | `transfer_stiffness_indirect(500.0, 0.01, blocking_mass=10.0)` |
| `TRANSMISSIBILITY_LIMIT` | `float` | **Indirect-method validity limit (ISO 10846-3 6.1, Inequality 2).**<br>0.1: \|T\| ≤ 0.1 ⇔ ΔL1,2 ≥ 20 dB keeps Formula (1) within 1 dB (12 %) | `TRANSMISSIBILITY_LIMIT  # 0.1` |
| `REFERENCE_STIFFNESS` | `float` | **Reference stiffness k0 for the level Lk [N/m] (ISO 10846-2/-3, 3.17).**<br>1.0 | `REFERENCE_STIFFNESS  # 1.0` |
| `blocking_force_ratio` | `function` | **Delivered/blocking force ratio (ISO 10846-1 Eq. 6).**<br>• `driving_point_stiffness` k₂,₂ [N/m] (complex)<br>• `termination_stiffness` k_t [N/m] (complex, non-zero)<br>F₂/F₂,b = 1/(1 + k₂,₂/k_t); within 10 % of 1 for \|k₂,₂\| < 0.1\|k_t\| | `blocking_force_ratio(1e5, 1e6)  # 0.909` |
| `base_transmissibility` | `function` | **Mass-loaded Kelvin-Voigt transmissibility (model).**<br>• `frequency` f [Hz]<br>• `mass` m [kg], `stiffness` k [N/m], `damping` c [N·s/m] (Default: 0.0)<br>T = (k+jωc)/(k−ω²m+jωc) | `base_transmissibility(f, 8.0, 1e6, 120.0)` |
| `indirect_transfer_stiffness_result` | `function` | **Indirect-method sweep → result.**<br>• `frequency` f [Hz] (array)<br>• `transmissibility` T (complex)<br>• `blocking_mass` m₂ [kg], `flange_mass` (Default: 0.0) | `res = indirect_transfer_stiffness_result(f, t, 8.0)`<br><br>• `TransferStiffnessResult` |
| `TransferStiffnessResult` | `dataclass` | **Dynamic transfer stiffness over frequency (ISO 10846).**<br>• `frequencies` [Hz], `transfer_stiffness` k₂,₁ [N/m]<br>• `blocking_mass` m₂ [kg] or None<br>• `.magnitude` / `.level` (dB re 1 N/m) / `.loss_factor`<br>• `.to(target)`: "impedance"/"apparent_mass"<br>• `.plot()`: Lₖ(f) | `res.level, res.loss_factor` |
| `infinite_plate_impedance` / `infinite_plate_mobility` / `infinite_plate_point_mobility` | `function` | **Thin-plate point mobility Z = C√(B'm'') (Cremer Table 5.1).**<br>• `bending_stiffness` B' [N·m], `mass_per_area` m'' [kg/m²]<br>• `location`: 'centre' (C=8) / 'edge' (C=3.5); real, frequency-independent | `infinite_plate_impedance(1e4, 10)`<br><br>• float or `MobilityResult` |
| `infinite_beam_mobility` / `infinite_beam_impedance` / `infinite_beam_moment_mobility` / `infinite_beam_point_mobility` | `function` | **Slender-beam point mobility Y = (1−j)/(4m'cB) (Cremer Table 5.1).**<br>• `frequency` [Hz], `bending_stiffness` B [N·m²], `mass_per_length` m' [kg/m]<br>• `location`: 'centre'/'end'; 45°, ∝ ω^−½ | `infinite_beam_mobility(100, 200, 5)`<br><br>• complex or `MobilityResult` |
| `longitudinal_rod_impedance` / `injected_power` / `plate_bending_stiffness` | `function` | **Rod point impedance ρcₗS; injected power W = ½\|F\|²Re{Y} (Cremer 5.23); plate B' = Eh³/12(1−ν²).** | `injected_power(10.0, y)` |
| `radiation_efficiency` / `coincidence_frequency` | `function` | **Plate radiation efficiency σ(f) (Hopkins 2.9, Leppington/Maidanik).**<br>• `frequency` [Hz], `length_x`/`length_y` [m], `critical_frequency` fc [Hz]<br>• `boundary`: 'simply_supported'/'clamped', `baffle`: 'infinite'/'perpendicular'<br>• σ → 1 above fc; fc = (c₀²/2π)√(m''/B') | `radiation_efficiency(f, 1.5, 1.25, 2100)`<br><br>• `RadiationEfficiencyResult` |
| `RadiationEfficiencyResult` | `dataclass` | **Plate radiation efficiency over frequency (Hopkins 2.9.4).**<br>• `radiation_efficiency` σ, `radiation_index` 10 lg σ [dB]<br>• `critical_frequency` fc, `.plot()`; feeds `sound_power_from_vibration` as ε | `res.radiation_efficiency` |
| `junction_transmission` | `function` | **Bending-wave transmission of a rigid plate junction (Hopkins 5.2.1.3, Cremer 1973, Craik 1981/1996).**<br>• `junction`: 'X'/'T1'/'T2'/'L'<br>• two plates' `thickness` h, `wave_speed` cL, `surface_density` ρs<br>• `angles_deg` grid (Default: 0–90°) | `junction_transmission('X', 0.1, 3200, 240, 0.1, 3200, 240)`<br><br>• `JunctionTransmissionResult` |
| `JunctionTransmissionResult` | `dataclass` | **Angle-resolved bending-wave transmission at a rigid junction (Hopkins 5.2.1.3).**<br>• `chi`, `psi`, `critical_frequency1`/`critical_frequency2` fc [Hz], `angles_deg`, `corner` τ12(θ), `straight` τ13(θ) or None<br>• `corner_average`/`straight_average` (Eq. 5.6), `corner_reduction_index` Kij (symmetric), `.plot()` | `res.corner_average  # 1/12 for identical X` |
| `junction_wave_parameters` | `function` | **Wave parameters χ, ψ of a plate pair (Hopkins 5.10/5.11).**<br>• two plates' h [m], cL [m/s], ρs [kg/m²]<br>• χ = √(h₁cL₁/h₂cL₂) = √(fc₂/fc₁), ψ = h₂cL₂ρs₂/(h₁cL₁ρs₁) | `junction_wave_parameters(0.1, 3200, 240, 0.2, 3200, 480)  # (√0.5, 4)` |
| `corner_transmission_coefficient` / `straight_transmission_coefficient` | `function` | **τ12(θ) around a corner (Hopkins 5.12) / τ13(θ) across a straight section (5.13).**<br>• `angle` θ [rad], `chi`, `psi`, `junction`<br>• corner is 0 for χ < sinθ; straight only for 'X'/'T1' | `corner_transmission_coefficient(0.0, 1.0, 1.0, 'X')  # 1/8` |
| `inline_transmission_coefficient` | `function` | **Normal-incidence transmission across an in-line junction (Hopkins 5.14, Cremer 1973).**<br>• `chi`, `psi`; τ = 1 for identical plates | `inline_transmission_coefficient(1.0, 1.0)  # 1.0` |
| `angular_average_transmission_coefficient` | `function` | **Diffuse-field angular average ∫₀^{π/2} τ(θ)cosθ dθ (Hopkins 5.6).**<br>• `chi`, `psi`, `junction`, `section`: 'corner'/'straight' | `angular_average_transmission_coefficient(1.0, 1.0, 'X')  # 1/12` |
| `coupling_loss_factor` | `function` | **SEA coupling loss factor from τ (Hopkins 2.154).**<br>• `transmission_coefficient` τ, `group_velocity` cg [m/s]<br>• `junction_length` L [m], `frequency` f [Hz], `plate_area` S [m²]<br> η = cg·L·τ/(2π²fS) | `coupling_loss_factor(1/12, 200, 4, 500, 10)` |
| `wave_vibration_reduction_index` | `function` | **Wave-approach vibration reduction index Kij (Hopkins 5.116).**<br>• `transmission_coefficient` τ, `critical_frequency_receiver` fc_j [Hz]<br>Kij = 10 lg(1/τ) + 5 lg(fc_j/f_ref), f_ref = 1000 Hz (symmetric: Kij = Kji) | `wave_vibration_reduction_index(1/12, 1000.0)  # 10.8 dB` |
| `velocity_level` / `velocity_level_from_acceleration` | `function` | **Vibratory velocity level (ISO/TS 7849-1 Eq. 3/8).**<br>• `velocity_level(v, reference=5e-8)`: 20 lg(v/v₀) [dB]<br>• `velocity_level_from_acceleration(a_peak, f)`: 20 lg(â/(2πf·v₀·√2)) (sinusoidal calibration) | `velocity_level_from_acceleration(9.81, 100.0)  # 106.9 dB` |
| `REFERENCE_VELOCITY` | `float` | **Reference velocity v0 [m/s] (ISO/TS 7849-1).**<br>5e-8 | `REFERENCE_VELOCITY  # 5e-08` |
| `NORMALIZED_IMPEDANCE` | `float` | **Normalized characteristic impedance Zc,n of air [N·s/m³] (23 °C, 101,3 kPa).**<br>411.0 | `NORMALIZED_IMPEDANCE  # 411.0` |
| `mean_velocity_level` | `function` | **Mean surface velocity level (ISO/TS 7849-1 Eq. 10/11).**<br>• `levels` L_v,i [dB]<br>• `areas` S_i (Eq. 11) or None → energetic mean (Eq. 10) | `mean_velocity_level([60, 66, 63])` |
| `radiation_factor` | `function` | **A-weighted radiation factor ε (ISO/TS 7849 Eq. 4/8).**<br>• `sound_power` P [W], `area` S [m²]<br>• `mean_square_velocity` ⟨v²⟩ [(m/s)²]<br>• `impedance` Z_c (Default: 411 N·s/m³)<br>ε = P/(Z_c·⟨v²⟩·S) | `radiation_factor(3e-4, 2.0, 1e-6)  # 0.365` |
| `radiated_sound_power_level` | `function` | **Sound power level from vibration (ISO/TS 7849-1 Eq. 12, -2 Eq. 15).**<br>• `velocity_level` L_v [dB]<br>• `area` S [m²]<br>• `radiation_factor` ε (Default: 1.0 → Part 1 upper limit)<br>L_W = L_v + 10 lg(S/S₀) + 10 lg(ε) + 10 lg(411/400) | `radiated_sound_power_level(80.0, 1.6)` |
| `extraneous_velocity_correction` | `function` | **Correction K1A for extraneous vibration (ISO/TS 7849-1 Table 2).**<br>• `level_difference` ΔLv [dB]; ΔLv≥10→0, ΔLv<3→3 | `extraneous_velocity_correction(6.0)  # 1.0 dB` |
| `sound_power_from_vibration` | `function` | **Bundle a vibration → power determination.**<br>• `velocity_level` L_v per band [dB]<br>• `area` S [m²], `radiation_factor` ε (Default: 1.0)<br>• `frequencies` [Hz] or None | `res = sound_power_from_vibration(lv, 1.6, radiation_factor=eps, frequencies=f)`<br><br>• `VibrationSoundPowerResult` |
| `VibrationSoundPowerResult` | `dataclass` | **Sound power radiated by surface vibration (ISO/TS 7849).**<br>• `velocity_level` / `sound_power_level` / `radiation_factor` per band<br>• `area` S [m²], `frequencies` [Hz] or None<br>• `.total_level`: band-summed L_W [dB]<br>• `.plot()` | `res.sound_power_level, res.total_level` |
| `spatial_mean_velocity_level` / `plate_loss_factor` | `function` | **Reception-plate helpers (EN 15657 Formula 12/13).**<br>• `spatial_mean_velocity_level(levels)`: 10 lg(mean 10^(Lv,i/10)) [dB]<br>• `plate_loss_factor(f, Ts)`: η = 2.2/(f·Ts) | `spatial_mean_velocity_level([78, 80, 82])` |
| `mean_free_velocity_level` | `function` | **Mean free velocity level (ISO 9611:1996 eq. (9)).**<br>• `levels`: free-velocity levels at the N contact points [dB re 5e-8 m/s]<br>Energy mean over positions | `mean_free_velocity_level([70, 72, 74])  # 72.3 dB` |
| `structure_borne_power_level` | `function` | **Reception-plate injected power level (EN 15657 Formula 14).**<br>• `velocity_level` L_v [dB re 1e-9 m/s]<br>• `frequency` f [Hz], `mass_per_area` m [kg/m²], `area` S [m²]<br>• `loss_factor` η > 0<br>L_Ws = 10 lg(2πfηmS) + L_v − 60 (plate-specific; convert via Formulae 15/17 before EN 12354-5) | `structure_borne_power_level(80, 1000, 10, 1, 0.01)  # 47.98 dB` |
| `equivalent_blocked_force_level` / `characteristic_reception_plate_power` | `function` | **Source quantities (EN 15657 Formulae 15/17).**<br>• `equivalent_blocked_force_level(L_Ws_low, Y_plate)`: L_Fb,eq = L_Ws − 10 lg(Re Y/Y0) [dB re 1e-6 N]<br>• `characteristic_reception_plate_power(L_Fb,eq)`: L_Wsn = L_Fb,eq + 10 lg(Y_R,∞,low/Y0), Y_R,∞,low = 5e-6 m/(N·s) | `lwsn = characteristic_reception_plate_power(equivalent_blocked_force_level(61.7, 5.34e-6))` |
| `equivalent_free_velocity_level` / `source_mobility_from_levels` | `function` | **Source quantities (EN 15657 Formulae 18/19).**<br>• `equivalent_free_velocity_level(L_Ws_high, Y_plate)`: L_vf,eq [dB re 1e-9 m/s]<br>• `source_mobility_from_levels(L_vf,eq, L_Fb,eq)`: \|Y_S,eq\| [m/(N·s)] | `y_s = source_mobility_from_levels(lvf, lfb)` |
| `reception_plate_power` | `function` | **Reception-plate determination → result (EN 15657 clause 7).**<br>• `velocity_level` L_v (per band), `frequency` [Hz]<br>• `mass_per_area`, `area`<br>• `loss_factor` η **or** `reverberation_time` Ts | `res = reception_plate_power(lv, f, 600, 2.0, reverberation_time=0.8)`<br><br>• `StructureBornePowerResult` |
| `StructureBornePowerResult` | `dataclass` | **Reception-plate injected structure-borne power (EN 15657).**<br>• `power_level` L_Ws / `velocity_level` / `loss_factor` per band<br>• `mass_per_area`, `area`, `frequencies` [Hz] or None<br>• `.total_level`: band-summed L_Ws [dB]<br>• `.plot()` | `res.power_level, res.total_level` |
| `coupling_term` / `coupling_term_force_source` / `coupling_term_velocity_source` | `function` | **Coupling term D_C (EN 12354-5 Formula 19b/c/d).**<br>• `coupling_term(Ys, Yi, transfer_mobility=0)`: 10 lg(\|Ys+Yi+Yk\|²/(\|Ys\|·Re Yi))<br>• force source: 10 lg(\|Ys\|/Re Yi)<br>• velocity source: −10 lg(\|Ys\|·Re Zi) | `coupling_term(2e-4+1e-4j, 3e-5+1e-5j)` |
| `installed_structure_borne_power_level` | `function` | **Installed power (EN 12354-5 Formula 18b).**<br>• `characteristic_power_level` L_Ws,c [dB] (the converted EN 15657 level, not the raw Formula 14 plate power)<br>• `coupling_term` D_C [dB]<br>L_Ws,inst = L_Ws,c − D_C | `installed_structure_borne_power_level(82.0, 8.5)` |
| `installed_power_from_reception_plate` | `function` | **Annex I mobility correction (EN 12354-5).**<br>• `reception_plate_level` L_Ws,n [dB]<br>• `receiver_mobility` Y_∞,i [m/(N·s)]<br>• `plate_mobility` Y_∞,rec (Default: 5e-6)<br>L_Ws,inst = L_Ws,n + 10 lg(Y_∞,i/Y_∞,rec); with the source mobility it yields L_Ws,c | `installed_power_from_reception_plate(67.6, 1.25e-6)  # -6 dB` |
| `structure_borne_pressure_level_path` / `total_structure_borne_pressure_level` | `function` | **Path & total normalised SPL (EN 12354-5 Formula 18a/17).**<br>• path: L_Ws,inst − D_sa − R_ij,ref − 10 lg(S/S0) − 10 lg(A0/4)<br>• total: 10 lg(Σ 10^(L/10)) over paths (axis 0) | `total_structure_borne_pressure_level(path_levels)` |
| `REFERENCE_AREA` | `float` | **Reference area S0 = A0 [m²] (EN 12354-5 Formula 18a).**<br>10.0 | `REFERENCE_AREA  # 10.0` |
| `installed_source_prediction` | `function` | **Full installed-source prediction → result (EN 12354-5).**<br>• `characteristic_power_level` L_Ws,c, `coupling_term` D_C<br>• `paths`: dicts with `adjustment_term`, `flanking_reduction_index`, `element_area`<br>• `frequencies` [Hz] or None | `res = installed_source_prediction(lwc, dc, paths, frequencies=f)`<br><br>• `InstalledSourceResult` |
| `InstalledSourceResult` | `dataclass` | **Installed structure-borne sound prediction (EN 12354-5).**<br>• `path_levels` (paths×bands), `total_level` L_n,s per band<br>• `installed_power_level` per band, `frequencies` [Hz] or None<br>• `.overall_level`: band-summed total [dB]<br>• `.plot()`: the cascade | `res.total_level, res.overall_level` |
| `combine_uncertainty` | `function` | **GUM law of propagation of uncertainty (ISO/IEC Guide 98-3 clause 5).**<br>• `model`: measurement function f(x1…xN)<br>• `quantities`: input `Quantity` objects, in argument order<br>• `correlation`: optional N×N matrix r_ij (Default: None → uncorrelated; non-identity with finite input dof → effective dof undefined, `expanded()` needs `coverage_factor_override`) | `u = combine_uncertainty(lambda a, b: a*b, [Quantity(10.0, 0.1), Quantity(5.0, 0.2)])`<br><br>• `UncertaintyResult`; uc = 2.062 |
| `monte_carlo` | `function` | **Monte Carlo propagation (Guide 98-3-1, Supplement 1).**<br>• `model`: vectorised f(x1…xN)<br>• `quantities`: input `Quantity` objects<br>• `trials`: M ≥ 2 (Default: 1000000; fixed, no adaptive 7.9)<br>• `coverage`: interval probability (Default: 0.95)<br>• `seed`: reproducibility (Default: None)<br>• inputs sampled independently (no 6.4.8 multivariate path) | `mc = monte_carlo(model, quantities, seed=1)`<br><br>• `MonteCarloResult` (mean, u, coverage interval) |
| `rectangular` | `function` | **Type B quantity, rectangular PDF (GUM 4.3.7).**<br>• `value`, `half_width` a, `name` | `rectangular(20.0, 0.5)`<br><br>• `Quantity` with u = a/√3 = 0.289 |
| `triangular` | `function` | **Type B quantity, triangular PDF (GUM 4.3.9).**<br>• `value`, `half_width` a, `name` | `triangular(20.0, 0.5)`<br><br>• `Quantity` with u = a/√6 |
| `u_shaped` | `function` | **Type B quantity, U-shaped (arcsine) PDF.**<br>• `value`, `half_width` a, `name` | `u_shaped(20.0, 0.5)`<br><br>• `Quantity` with u = a/√2 |
| `Quantity` | `dataclass` | **Input quantity of a measurement model (GUM clause 4).**<br>• `value`: estimate xi<br>• `uncertainty`: u(xi) ≥ 0<br>• `distribution`: 'gaussian'/'rectangular'/'triangular'/'u-shaped' (Default: 'gaussian')<br>• `dof`: degrees of freedom (Default: inf)<br>• `name`: budget label | `Quantity(94.0, 0.3, name="calibrator")` |
| `UncertaintyResult` | `dataclass` | **GUM propagation result.**<br>• `value`: y = f(x1…xN)<br>• `combined_uncertainty`: uc(y)<br>• `sensitivities`: ci = ∂f/∂xi<br>• `contributions`: \|ci\|·u(xi)<br>• `effective_dof`: Welch–Satterthwaite<br>• `names`<br>• `.plot()`: uncertainty budget | `u.combined_uncertainty, u.contributions` |
| `MonteCarloResult` | `dataclass` | **Monte Carlo result.**<br>• `value`: sample mean<br>• `standard_uncertainty`: sample std<br>• `interval`: (low, high) coverage interval (§7.7)<br>• `coverage`, `trials` | `mc.value, mc.interval` |
| `UncertaintyWarning` | `warning class` | **GUM propagation advisory.**<br>Emitted when a propagation falls back outside its nominal assumptions | `warnings.simplefilter('error', UncertaintyWarning)` |
| `trend_test` | `function` | **Nonparametric trend test on a sequence (Bendat & Piersol 4.5.2).**<br>• `values`: ≥ 10 observations or estimates<br>• `method`: 'reverse_arrangements' (Default; Table A.6 acceptance regions, exact Mahonian p-value to N = 100) / 'runs' (about the median, exact Wald–Wolfowitz distribution; median ties discarded)<br>• `alpha`: two-sided significance (Default: 0.05) | `res = trend_test(values)`<br><br>• `TrendTestResult`; B&P Example 4.4: A = 86, accepted in (64, 125] |
| `TrendTestResult` | `dataclass` | **Trend-test verdict.**<br>• `statistic`: reverse arrangements A or runs r<br>• `bounds`: acceptance region (lower, upper], `p_value`, `trend_free`<br>• `mean`, `std`: null moments (Eqs. 4.54–4.55)<br>• `values`, `method`, `n`, `alpha`, `median` (runs-classification median)<br>• `.plot()`: tested sequence with the verdict | `res.statistic, res.bounds, res.trend_free` |
| `stationarity_test` | `function` | **Stationarity test on segment statistics (Bendat & Piersol 10.3.1.1).**<br>• `x`, `fs`<br>• `n_segments` (Default: 20, as B&P Example 10.3), `statistic`: 'mean_square' (Default) / 'rms' / 'mean' / 'variance'<br>• `method`, `alpha` as `trend_test`<br>Trailing samples beyond equal segments are discarded | `res = stationarity_test(x, fs)`<br><br>• `StationarityTestResult` |
| `StationarityTestResult` | `dataclass` | **Stationarity verdict on a record.**<br>• `segment_values`, `segment_times` [s], `segment_duration` [s]<br>• `count`, `bounds`, `p_value`, `stationary`, `mean`, `std`<br>• `statistic`, `method`, `alpha`, `n_segments`, `fs`<br>• `.plot()`: segment sequence with the verdict | `res.stationary, res.segment_values` |
| `level_crossing_rate` | `function` | **Level-crossing rates vs the Rice expectation (Bendat & Piersol 5.5.1).**<br>• `x`, `fs` (mean removed first; oversample the band)<br>• `levels` [signal units] (Default: 13 levels over ±3 RMS)<br>• `nperseg`: Welch length for the spectral moments<br>Gaussian: N0 = 2√(m2/m0), Na = N0·exp(−a²/2σ²) | `res = level_crossing_rate(x, fs)`<br><br>• `LevelCrossingResult` |
| `LevelCrossingResult` | `dataclass` | **Crossing rates (both slopes).**<br>• `levels`, `rates` [1/s], `rice_rates` [1/s]<br>• `zero_crossing_rate`, `zero_crossing_rate_rice`, `apparent_frequency` [Hz] = N0/2<br>• `sigma`, `duration` [s], `fs`<br>• `.plot()`: measured dots vs the Rice curve | `res.zero_crossing_rate, res.apparent_frequency` |
| `peak_statistics` | `function` | **Peak rate, irregularity factor and peak heights (Bendat & Piersol 5.5.2–5.5.4).**<br>• `x`, `fs` (mean removed; band-limit before: m4 weights G(f) by f⁴)<br>• `nperseg`: Welch length for the moments<br>M = √(m4/m2), r = N0/2M ∈ (0, 1] | `res = peak_statistics(x, fs)`<br><br>• `PeakStatisticsResult` |
| `PeakStatisticsResult` | `dataclass` | **Peak (maxima) statistics.**<br>• `peak_rate`, `peak_rate_rice` [1/s], `zero_crossing_rate_rice`, `irregularity_factor`<br>• `peak_values`: sorted standardized heights z = a/σ<br>• `.peak_exceedance(z)` / `.peak_density(z)`: Rice mixture (Eqs. 5.217/5.223; Rayleigh at r = 1, Gaussian at r → 0)<br>• `.plot()`: empirical exceedance vs the Rice curves | `res.irregularity_factor, res.peak_exceedance(4.0)` |
| `power_spectral_density` | `function` | **Welch PSD with statistical error analysis (Bendat & Piersol Ch. 8).**<br>• `x`, `fs`<br>• `window` (Default: 'hann'), `nperseg` (Default: bin spacing of at most 4 Hz; the resolution bandwidth Be also depends on the taper ENBW), `overlap` (Default: 0.5)<br>• `scaling`: 'density' [units²/Hz] / 'spectrum' [units²]<br>• `confidence`: chi-square CI level (Default: 0.95) | `res = power_spectral_density(x, fs)`<br><br>• `SpectralDensityResult` (psd, CI, nd, ε) |
| `cross_spectral_density` | `function` | **Welch cross-spectral density with magnitude/phase errors (Eqs. 9.33/9.52).**<br>• `x`, `y`, `fs`<br>• `window`, `nperseg`, `overlap`, `scaling` as above | `res = cross_spectral_density(x, y, fs)`<br><br>• `CrossSpectralDensityResult` |
| `coherent_output_spectrum` | `function` | **Gvv = γ²·Gyy, noise remainder and spectral SNR (Eqs. 9.55–9.56, 9.73).**<br>• `x` (input), `y` (output), `fs`<br>• `window`, `nperseg`, `overlap`, `scaling` as above | `res = coherent_output_spectrum(x, y, fs)`<br><br>• `CoherentOutputSpectrumResult` |
| `fractional_octave_smoothing` | `function` | **Constant-power 1/n-octave smoothing of a spectrum.**<br>• `frequencies`, `values`<br>• `fraction`: the n of 1/n octave (Default: 3)<br>• `domain`: 'power'/'amplitude'/'db' (Default: 'power') | `s = fractional_octave_smoothing(f, psd, 3.0)`<br><br>• smoothed array, same domain; flat spectra unchanged |
| `miso_coherence` | `function` | **Multiple & partial coherence of a MISO system (Bendat & Piersol Ch. 7).**<br>• `inputs`: q input records, q ≥ 2 (sequence of 1-D arrays or a (q, n) array), `output`, `fs`<br>• `order`: conditioning order (Default: 0..q−1)<br>• `window`, `nperseg`, `overlap`, `scaling` as above | `res = miso_coherence([x1, x2], y, fs)`<br><br>• `MISOCoherenceResult` |
| `MISOCoherenceResult` | `dataclass` | **Multiple/partial coherence of q correlated inputs and one output.**<br>• `ordinary_coherence` (q×F), `multiple_coherence` γ²y:x, `partial_coherence` (q×F, conditioned per `order`)<br>• `coherent_output_spectra` Gvi (q×F, Σ Gvi + `noise_psd` = `output_psd`)<br>• `multiple_coherence_random_error` (Eq. 9.98), `coherent_output_random_error` (Eq. 9.100)<br>• `.dominant_input()`: per-band index of the strongest source<br>• `.plot()`: coherent output spectra + coherences | `res.multiple_coherence, res.dominant_input()` |
| `resolution_bias_error` | `function` | **First-order resolution bias at a resonance peak, εb ≈ −(Be/Br)²/3 (Eq. 8.141).**<br>• `resolution_bandwidth` Be [Hz]<br>• `half_power_bandwidth` Br [Hz] | `resolution_bias_error(1.0, 4.0)  # -1/48` |
| `noise_signal` | `function` | **Colored Gaussian noise with an exact power-law PSD slope.**<br>• `fs`, `seconds` (Default: 1.0)<br>• `color`: 'white'/'pink'/'red'/'blue'/'violet' (0/−3.01/−6.02/+3.01/+6.02 dB/oct)<br>• `rms` (Default: 1.0)<br>• `seed`: bit-reproducible per seed | `pink = noise_signal(48000, 10.0, color='pink', seed=7)` |
| `tone_burst` | `function` | **IEC 60268-1 tone burst (Clause A2): zero-crossing start, integral full periods.**<br>• `fs`, `frequency`, `cycles`<br>• `amplitude` (Default: 1.0)<br>• `repetitions` + `repetition_rate` [bursts/s]: repetitive train (Clause A2.2)<br>• `pre_silence`, `post_silence` [s] | `res = tone_burst(48000, 5000, 25, repetition_rate=10, repetitions=4)`<br><br>• `ToneBurstResult` |
| `resample_signal` | `function` | **Polyphase resampling with an explicit anti-alias spec (Kaiser FIR designed here).**<br>• `x`, `fs`, `fs_new` (rational ratio, e.g. 160/147)<br>• `stopband_attenuation_db` (Default: 120)<br>• `transition_width`: fraction of the smaller Nyquist (Default: 0.05)<br>• `max_denominator` (Default: 1000) | `res = resample_signal(x, 44100, 48000)`<br><br>• `ResampledSignalResult` (signal + designed taps) |
| `fractional_delay` | `function` | **Band-limited delay by a fractional number of samples (phase ramp).**<br>• `x`, `delay` [samples] (fractional, negative = advance)<br>• `mode`: 'linear' (zero-padded, for transients; Default) / 'circular' (periodic records, machine-exact on bin-centered tones) | `y = fractional_delay(x, 0.37)` |
| `window_metrics` | `function` | **Figures of merit of any scipy window (Harris 1978).**<br>• `window`: name or (name, param) tuple<br>• `n`: length (Default: 1024), DFT-even sampling as in Welch | `m = window_metrics('hann', 2048)`<br><br>• `WindowMetricsResult` |
| `multitaper_psd` | `function` | **Thomson multitaper PSD from one whole record (Percival & Walden Ch. 7).**<br>• `x`, `fs`<br>• `time_half_bandwidth`: NW (Default: 4), `n_tapers`: K (Default: 2·NW−1, capped at 2·NW)<br>• `adaptive`: Thomson weights (Default: True)<br>• `scaling`, `confidence` as above | `res = multitaper_psd(x, fs)`<br><br>• `MultitaperSpectralDensityResult` |
| `ToneBurstResult` | `dataclass` | **Tone-burst record.**<br>• `signal`, `envelope` (rectangular gate), `fs`<br>• `burst_seconds`, `burst_samples`, `onset_sample`<br>• `repetition_rate`, `period_samples`, `duty_cycle`<br>• `.plot()`: waveform + gating envelope | `res.signal, res.duty_cycle` |
| `ResampledSignalResult` | `dataclass` | **Resampled record with its anti-alias design.**<br>• `signal`, `fs`, `original_fs`, `up`, `down`<br>• `filter_taps`, `n_taps`, `passband_edge_hz`, `stopband_edge_hz`<br>• `stopband_attenuation_db`, `transition_width` | `res.signal, res.stopband_attenuation_db` |
| `WindowMetricsResult` | `dataclass` | **Window figures of merit.**<br>• `enbw_bins`, `.enbw_hz(fs)`, `coherent_gain`<br>• `scalloping_loss_db`, `worst_case_processing_loss_db` (positive losses)<br>• `highest_sidelobe_db` (negative, re main lobe), `mainlobe_width_3db_bins`<br>• `.plot()`: window + spectrum with metrics marked | `m.enbw_bins  # 1.5 for Hann` |
| `SpectralDensityResult` | `dataclass` | **Welch PSD with its statistical quality.**<br>• `frequencies`, `psd`, `ci_lower`/`ci_upper`, `confidence`<br>• `random_error` = 1/√nd, `n_segments`, `n_averages` (effective nd), `degrees_of_freedom`<br>• `resolution_bandwidth` Be [Hz]<br>• `.plot()`: PSD in dB with the CI band | `res.psd, res.n_averages, res.random_error` |
| `MultitaperSpectralDensityResult` | `dataclass` | **Multitaper PSD with per-frequency dof.**<br>• `frequencies`, `psd`, `ci_lower`/`ci_upper`, `confidence`<br>• `degrees_of_freedom` ν(f), `random_error` = √(2/ν)<br>• `weights` (K×F), `eigenvalues` λk, `time_half_bandwidth`, `n_tapers`, `resolution_bandwidth` 2W [Hz], `adaptive`<br>• `.plot()`: density with the CI band | `res.psd, res.degrees_of_freedom` |
| `CrossSpectralDensityResult` | `dataclass` | **Welch CSD with per-bin errors.**<br>• `csd` (complex), `magnitude`, `phase` (unwrapped), `coherence`<br>• `magnitude_random_error`, `phase_std` [rad]<br>• `.plot()`: magnitude, phase ±σ, coherence | `res.magnitude, res.phase_std` |
| `CoherentOutputSpectrumResult` | `dataclass` | **SISO output decomposition.**<br>• `output_psd` Gyy, `coherent_psd` Gvv, `noise_psd` Gnn<br>• `snr`, `snr_db`, `random_error` (Eq. 9.73), `snr_random_error`, `coherence_bias`<br>• `.plot()`: spectra + SNR panel | `res.coherent_psd, res.snr_db` |
| `spectrogram` | `function` | **Calibrated STFT power spectrogram (B&P 12.6.4.2): the Welch segmentation without the averaging.**<br>• `x`, `fs`<br>• `window`, `nperseg`, `overlap`, `scaling` as above<br>Column mean = Welch PSD bin by bin; a tone reads A²/2 ('spectrum') in every column | `res = spectrogram(x, fs, nperseg=1024, overlap=0.75)`<br><br>• `SpectrogramResult` |
| `zoom_fft` | `function` | **Narrow-band spectrum on a fine grid via chirp-Z (B&P 11.5.4).**<br>• `x`, `fs`, `f_min`, `f_max`<br>• `n_points` (Default: one per record resolution fs/N)<br>• `window` (Default: 'hann')<br>Tone on an analysis frequency reads amplitude A / power A²/2 exactly | `res = zoom_fft(x, fs, 980.0, 1016.0)`<br><br>• `ZoomFFTResult` |
| `SpectrogramResult` | `dataclass` | **STFT power over the time-frequency plane.**<br>• `times`, `frequencies`, `power` (freq × time)<br>• `time_resolution` T_B [s], `resolution_bandwidth` Be [Hz], `random_error` (= 1, unaveraged), `n_segments`, `hop`<br>• `.plot()`: dB image (raster) | `res.power[:, 0], res.time_resolution` |
| `ZoomFFTResult` | `dataclass` | **Zoom spectrum with amplitude calibration.**<br>• `frequencies`, `spectrum` (complex, \|·\| = tone amplitude), `amplitude`, `power` (= A²/2)<br>• `bin_spacing` (grid) vs `resolution_bandwidth` Be (true resolution)<br>• `.plot()`: power in dB over the band | `res.frequencies[res.amplitude.argmax()]` |
| `correlation` | `function` | **Auto/cross-correlation via zero-padded FFT (B&P 11.4.2).**<br>• `x`, `y` (Default: None → autocorrelation), `fs`<br>• `normalization`: 'biased' (1/N) / 'unbiased' (1/(N−\|r\|)) / 'coefficient' (ρ ∈ [−1, 1]) (Default: 'unbiased')<br>• `max_lag` [s] (Default: full N−1) | `res = correlation(x, y, fs, normalization='coefficient')`<br><br>• `CorrelationResult` |
| `correlation_random_error` | `function` | **ε[R̂xy(τ)] = [1+ρ⁻²]^½/√(2BT) (Eqs. 8.109/8.112).**<br>• `coefficient` ρxy(τ)<br>• `signal_bandwidth` B [Hz], `duration` T [s]<br>Valid for T ≥ 10·\|τ\|, BT ≥ 5 | `correlation_random_error(1/11, 100.0, 5.0)  # 0.349 (Example 8.5)` |
| `time_delay` | `function` | **TDE: direct correlator, GCC (Knapp & Carter) or phase slope (Eq. 5.101b).**<br>• `x`, `y`, `fs`<br>• `method`: 'gcc' (Default) / 'direct' / 'phase'<br>• `weighting`: 'none'/'roth'/'scot'/'phat' (Default)/'ml' (Table I)<br>• `window`, `nperseg`, `overlap`: shared Welch core ('gcc'/'phase')<br>• `max_delay` [s], `interpolation`: 'parabolic' (Default)/'none', `upsample` (Default: 1)<br>• `signal_bandwidth` [Hz]: enables the Eq. 8.129 delay uncertainty | `res = time_delay(x, y, fs, weighting='phat', upsample=16)`<br><br>• `TimeDelayResult` |
| `impulse_response_delay` | `function` | **Sub-sample IR delay [s]: peak location refined by local upsampling + parabola.**<br>• `ir`, `fs`<br>• `reference`: optional IR the delay is measured against (direct correlator)<br>• `interpolation` (Default: 'parabolic'), `upsample` (Default: 8) | `t0 = impulse_response_delay(ir, fs)  # ~1e-3-sample accuracy` |
| `align_impulse_responses` | `function` | **Align an IR onto a reference by its estimated sub-sample delay.**<br>• `ir`, `reference`, `fs`<br>• `interpolation`, `upsample` as above<br>Exact band-limited fractional shift (zero-padded phase ramp) | `res = align_impulse_responses(ir_b, ir_a, fs)`<br><br>• `AlignedImpulseResponseResult` |
| `envelope` | `function` | **Hilbert envelope, instantaneous phase & frequency (B&P Ch. 13).**<br>• `x`, `fs`<br>• `decimation_factor` (Default: 1)<br>• `antialias`: zero-phase FIR decimation (Default: True; False = plain subsampling, the ECMA-internal convention) | `res = envelope(x, fs, decimation_factor=32)`<br><br>• `EnvelopeResult` |
| `CorrelationResult` | `dataclass` | **Correlation estimate.**<br>• `lags` [s], `values`, `coefficient` (always carried), `normalization`, `kind`<br>• `.random_error(signal_bandwidth)`: per-lag ε (Eqs. 8.109/8.112)<br>• `.plot()` | `res.lags, res.values, res.random_error(2000.0)` |
| `TimeDelayResult` | `dataclass` | **Time-delay estimate.**<br>• `delay` [s], `delay_samples` (fractional), `method`, `weighting`<br>• `lags`, `correlation` (the searched curve), `peak_correlation` ρ̂xy<br>• `delay_std` (Eq. 8.129), `delay_interval` ±2σ (Eq. 8.130) with `signal_bandwidth`<br>• `.plot()`: correlation with the delay marked | `res.delay, res.delay_std` |
| `AlignedImpulseResponseResult` | `dataclass` | **Aligned IR pair.**<br>• `aligned`, `reference`, `delay` [s], `delay_samples`<br>• `.plot()`: overlay | `res.aligned, res.delay_samples` |
| `EnvelopeResult` | `dataclass` | **Envelope analysis.**<br>• `times` [s], `envelope`, `phase` [rad, unwrapped], `instantaneous_frequency` [Hz]<br>• `fs` (output rate), `signal`, `signal_fs`, `decimation_factor`, `antialias`<br>• `.plot()`: signal + envelope, instantaneous frequency | `res.envelope, res.instantaneous_frequency` |
| `envelope_spectrum` | `function` | **Amplitude spectrum of the envelope: modulations as lines (B&P 13.3).**<br>• `x`, `fs`<br>• `kind`: 'magnitude' (Default) / 'squared' (square-law detector, Fig. 13.11)<br>• `window` (Default: 'hann', coherent-gain corrected), `nfft`<br>• `remove_dc` (Default: True; mean kept in `mean_level`)<br>AM tone A0, m: line A0·m at fm ('magnitude') | `res = envelope_spectrum(x, fs)`<br><br>• `EnvelopeSpectrumResult` |
| `EnvelopeSpectrumResult` | `dataclass` | **Envelope spectrum.**<br>• `frequencies` [Hz], `amplitude` (line heights), `mean_level`, `kind`<br>• `times`, `envelope` (detector output), `window`, `remove_dc`, `fs`, `nfft`<br>• `.plot()`: envelope + spectrum | `res.amplitude, res.mean_level` |
| `time_synchronous_average` | `function` | **Extract a periodic waveform of known period by time domain averaging (McFadden 1987 Eq. 5).**<br>• `x`, `fs`, `period` [s] (one revolution)<br>• `n_averages`: whole periods to average (Default: as many as the record holds; choose N so N·q is integer to place a comb node on an interfering order q)<br>• `n_harmonics`: comb-response span (Default: 8)<br>Non-integer fs·period aligned by band-limited fractional delay | `res = time_synchronous_average(x, fs, 1/32)`<br><br>• `SynchronousAverageResult` |
| `SynchronousAverageResult` | `dataclass` | **Time synchronous average.**<br>• `period_waveform`, `times` [s], `residual`, `residual_rms`<br>• `n_averages`, `samples_per_period`, `period`, `fs`, `interpolated`<br>• `noise_reduction_db` = 10·log₁₀N, `amplitude_snr_gain` = √N<br>• `comb_frequencies` [Hz], `comb_response` (\|C(f)\|)<br>• `.plot()`: averaged waveform + comb filter | `res.period_waveform, res.noise_reduction_db` |
| `comb_filter_response` | `function` | **Magnitude of the N-period synchronous-averaging comb filter (McFadden 1987 Eq. 8).**<br>• `frequencies` [Hz], `period` [s], `n_averages`<br>\|C(f)\| = \|sin(N·π·f·T)/(N·sin(π·f·T))\|: unit teeth at harmonics k/T, nodes at j/(N·T) | `c = comb_filter_response(freqs, 1/32, 20)` |
| `regularized_inverse_filter` | `function` | **Kirkeby frequency-dependent regularized inversion (Kirkeby & Nelson 1999 Eq. (17)).**<br>• `response`: measured IR (array or `ImpulseResponseResult`; its `fs` rides along)<br>• `fs`: Sample rate [Hz]<br>• `f_range`: (f1, f2) equalized to unity (Required)<br>• `regularization_inside` (Default: 1e-6) / `regularization_outside` (Default: 1.0), fractions of max\|H\|²<br>• `transition_octaves` (Default: 1/3), `n_fft`, `delay` (Default: n_fft//2) | `inv = regularized_inverse_filter(ir, f_range=(100, 10000))`<br><br>• `InverseFilterResult` |
| `InverseFilterResult` | `dataclass` | **Regularized inverse filter.**<br>• `inverse` (time domain), `spectrum` (with modeling delay), `response_spectrum`, `regularization` = ε(f), `frequencies`, `f_range`, `delay`, `fs`<br>• `flatness_db`: worst in-band deviation of \|H·H_inv\| from 0 dB<br>• `max_gain_db`: out-of-band boost (≤ the 1/(2√ε) cap)<br>• `.apply(x)`: equalize, delay removed; `.plot()` | `flat = inv.apply(recording)` |
| `cepstrum` | `function` | **Power/real/complex cepstrum (Havelock Chs. 27/87).**<br>• `x`, `fs`<br>• `kind`: 'power' (Default, IDFT of ln\|X\|²) / 'real' (ln\|X\|) / 'complex' (invertible, phase unwrapped)<br>• `nfft`: even, ≥ record (Default: record length)<br>Echo a at t0: rahmonics (−1)^(n+1)·aⁿ/n at n·t0 | `res = cepstrum(x, fs, kind='complex')`<br><br>• `CepstrumResult` |
| `CepstrumResult` | `dataclass` | **Cepstrum.**<br>• `quefrencies` [s], `cepstrum`, `kind`, `fs`, `nfft`, `linear_phase_samples`<br>• `.invert()`: homomorphic round trip (complex kind only)<br>• `.plot()`: cepstrum vs quefrency | `res.cepstrum, res.invert()` |
| `lifter` | `function` | **Lifter a log spectrum: envelope vs fine structure (Havelock Ch. 27).**<br>• `x`, `fs`, `cutoff` [s]<br>• `mode`: 'lowpass' (Default, spectral envelope) / 'highpass' (ripple)<br>• `nfft`<br>Modes exactly complementary in dB | `res = lifter(x, fs, cutoff=0.004)`<br><br>• `LifterResult` |
| `LifterResult` | `dataclass` | **Liftered log spectrum.**<br>• `frequencies` [Hz], `spectrum_db`, `liftered_db`<br>• `quefrencies`, `cepstrum` (real), `cutoff`, `mode`, `fs`, `nfft`<br>• `.plot()`: cepstrum + cutoff, spectrum overlay | `res.liftered_db` |
| `echo_detection` | `function` | **Echo delay + reflection coefficient off the power cepstrum.**<br>• `x`, `fs`<br>• `min_quefrency` [s] (Default: 16 samples), `max_quefrency` [s] (Default: nfft/2)<br>• `nfft`<br>Peak height = a exactly for one in-record echo | `res = echo_detection(ir, fs, min_quefrency=0.002)`<br><br>• `EchoDetectionResult` |
| `EchoDetectionResult` | `dataclass` | **Detected echo.**<br>• `delay` [s], `delay_samples`, `reflection_coefficient`<br>• `quefrencies`, `cepstrum`, `search_range`, `fs`, `nfft`<br>• `.plot()`: cepstrum with the peak marked | `res.delay, res.reflection_coefficient` |
| `minimum_phase` | `function` | **Minimum-phase response from \|H\| via the real cepstrum (B&P 13.1.4).**<br>• `response`: one-sided response or plain magnitude, rfft layout (DC–Nyquist)<br>• `oversample`: trig-interpolated cepstral anti-aliasing factor (Default: 8)<br>Input phase ignored; magnitude zeros floored at 1e-15 of the peak | `h_min = minimum_phase(np.abs(H))`<br><br>• complex minimum-phase response, same bins |
| `group_delay` | `function` | **Group delay τ_g = −(1/2π)·dφ/df of a sampled response.**<br>• `response`: one-sided complex, rfft layout<br>• `fs` [Hz]<br>Unwrapped phase, central differences; needs < π phase advance per bin | `tau = group_delay(H, fs)  # seconds` |
| `excess_phase` | `function` | **Excess (all-pass) phase: unwrap(arg H) − φ_min.**<br>• `response`: one-sided complex, rfft layout<br>• `oversample` (Default: 8)<br>0 for minimum phase, −2πf·t₀ for pure latency; the realizability limit of any stable causal inverse | `phi_x = excess_phase(H)  # rad, 0 at DC` |
| `phase_decomposition` | `function` | **Minimum-phase / all-pass decomposition of a response.**<br>• `response`: one-sided complex, rfft layout (e.g. `np.fft.rfft(ir)`)<br>• `fs` [Hz]<br>• `oversample` (Default: 8) | `res = phase_decomposition(np.fft.rfft(ir), fs)`<br><br>• `PhaseDecompositionResult` |
| `PhaseDecompositionResult` | `dataclass` | **Phase decomposition.**<br>• `frequencies` [Hz], `magnitude`, `phase` (measured, unwrapped, 0 at DC)<br>• `minimum_phase`, `excess_phase` [rad], `group_delay`, `excess_group_delay` [s]<br>• `minimum_phase_response` (complex), `fs`<br>• `.plot()`: magnitude, phases, group delays | `res.excess_phase, res.excess_group_delay` |
| `sound_power_anechoic` | `function` | **Precision sound power in an (hemi-)anechoic room (ISO 3745:2012).**<br>• `levels_positions`: (NM, NB) position levels [dB]<br>• `surface`: 'sphere'/'hemisphere'<br>• `radius` r [m]<br>• `background_levels` + `frequencies`: for K1i<br>• `areas`: partial areas Si (Eq. 13) (Default: equal-area Eq. 12)<br>• `temperature` [°C] (Default: 23), `static_pressure` [kPa] (Default: 101.325)<br>• `air_absorption_coefficient`: α(f) [dB/m] for C3<br>• `sigma_omc` [dB] (Default: 0), `coverage_factor` k (Default: 2.0) | `res = sound_power_anechoic(levels, 'hemisphere', radius=2.0, frequencies=f)`<br><br>• `PrecisionSoundPowerResult` |
| `PrecisionSoundPowerResult` | `dataclass` | **ISO 3745 sound power result.**<br>• `sound_power_level` [dB], `sound_power_level_a` [dB]<br>• `surface_pressure_level`, `mean_pressure_level` [dB]<br>• `background_correction`: K1i (NM, NB) [dB]<br>• `c1`, `c2`, `c3`: meteorological corrections [dB]<br>• `directivity_index` DIi, `non_uniformity_index` VIr [dB]<br>• `surface_area` [m²], `surface`<br>• `uncertainty`, `uncertainty_bands`, `coverage_factor`<br>• `.plot()` | `res.sound_power_level, res.uncertainty` |
| `precision_positions` | `function` | **ISO 3745 microphone coordinates (Annex D/E).**<br>• `surface`: 'sphere' (Table D.1) / 'hemisphere' (Tables E.1/E.2)<br>• `radius` r [m]<br>• `array`: 'general'/'broadband' (Default: 'general')<br>• `count`: 20 or 40 (Default: 40) | `xyz = precision_positions('hemisphere', radius=2.0)`<br><br>• (40, 3) coordinates [m] |
| `precision_background_correction` | `function` | **Per-position background correction K1i (ISO 3745 Eq. 11).**<br>• `source_levels` / `background_levels`: (NM, NB) [dB]<br>• `frequencies` [Hz]: per-band criterion (10 dB for 250–5000 Hz, 6 dB outside) | `k1 = precision_background_correction(src, bg, f)`<br><br>• K1i [dB]; clamped + `SoundPowerWarning` below the criterion |
| `meteorological_corrections` | `function` | **Corrections C1, C2, C3 (ISO 3745 Eq. 14 block).**<br>• `temperature` θ [°C] (Default: 23), `static_pressure` ps [kPa] (Default: 101.325)<br>• `air_absorption_coefficient`: α(f) [dB/m] for C3 (Default: None → C3 = 0)<br>• `radius` r [m] (Default: 1.0) | `met = meteorological_corrections(23.0, 101.325)`<br><br>• `MeteorologicalCorrection`; C1 = −0.128 dB, C2 = 0 at reference |
| `MeteorologicalCorrection` | `dataclass` | **C1/C2/C3 corrections.**<br>• `c1`: reference-quantity correction [dB]<br>• `c2`: radiation-impedance correction [dB]<br>• `c3`: air-absorption correction [dB], scalar or per band | `met.c1, met.c2, met.c3` |
| `precision_uncertainty` | `function` | **Expanded uncertainty U = k·√(σR0² + σomc²) (ISO 3745 Eq. 24/25).**<br>• `sigma_r0`: reproducibility (Tables 2/3) [dB]<br>• `sigma_omc` [dB] (Default: 0.0)<br>• `coverage_factor` k (Default: 2.0; 1.6 one-sided) | `precision_uncertainty(0.5)  # 1.0`<br><br>• U [dB], scalar or per band |
| `sound_power_intensity_precision` | `function` | **Sound power by intensity scanning, precision (ISO 9614-3:2002).**<br>• `partial_intensity`: (N, NB) signed In,i [W/m²]<br>• `areas`: partial areas Si [m²]<br>• `frequencies` [Hz]: for LWA<br>• `temperature` [°C] (Default: 23), `barometric_pressure` [Pa] (Default: 101325): for LW0 (Eq. 10) | `res = sound_power_intensity_precision(In, areas, frequencies=f)`<br><br>• `PrecisionIntensityResult` |
| `PrecisionIntensityResult` | `dataclass` | **ISO 9614-3 scanning result.**<br>• `partial_power`: Pi = In,i·Si [W]<br>• `sound_power` P, `sound_power_level` LW [dB] (NaN where P ≤ 0)<br>• `sound_power_level_normalized`: LW0 [dB]<br>• `not_applicable_band`: per-band bool<br>• `surface_area` [m²], `sound_power_level_a` [dB]<br>• `.plot()` | `res.sound_power_level, res.not_applicable_band` |
| `precision_field_indicators` | `function` | **ISO 9614-3 Annex B field indicators.**<br>• `segment_intensity`: (N, NB) signed In,j [W/m²]<br>• `segment_pressure_levels`: (N, NB) Lpj [dB]<br>• `time_window_intensity`: (M, NB) for FT (Default: None) | `fi = precision_field_indicators(In, lp)`<br><br>• `PrecisionFieldIndicators` |
| `PrecisionFieldIndicators` | `dataclass` | **Annex B indicators (per band).**<br>• `ft`: temporal variability (= F1) or None<br>• `f_pi_unsigned`: F_pIn unsigned (= F2)<br>• `f_pi_signed`: F_pIn signed (= F3), ≥ unsigned<br>• `fs`: field non-uniformity FS (= F4) | `fi.f_pi_signed, fi.fs` |
| `precision_qualification` | `function` | **The five ISO 9614-3 Annex C acceptance criteria.**<br>• `indicators`: `PrecisionFieldIndicators`<br>• `scan_intensity_level_1`/`_2`: LIn per scan [dB] (criterion 1)<br>• `pressure_residual_index`: δpI0 [dB] (criterion 2, K = 10)<br>• `field_nonuniformity_1`/`_2`: FS per scan density (criterion 5)<br>• `frequencies` [Hz] or `repeatability_limit`: Table 1 limit s | `q = precision_qualification(fi, pressure_residual_index=18.0)`<br><br>• `PrecisionCriteria` |
| `PrecisionCriteria` | `dataclass` | **Annex C pass/fail per band.**<br>• `criterion_1`…`criterion_5`: bool arrays or None where not evaluable<br>• `qualified`: conjunction of criteria 1–4 or None | `q.qualified, q.criterion_2` |
| `plane_wave_frequency_range` | `function` | **Working plane-wave range (f_l, f_u) (ISO 10534-2 §4.2–4.5).**<br>• `spacing` s [m]<br>• `speed_of_sound` c0 [m/s]<br>• `diameter` d [m] (Default: None → spacing bound only)<br>• `shape`: 'circular'/'rectangular' (Default: 'circular') | `plane_wave_frequency_range(0.05, 343.2, diameter=0.1)  # (343.2, 1990.6)`<br><br>• [Hz] |
| `speed_of_sound_iso` | `function` | **Speed of sound (ISO 10534-2 Eq. 5).**<br>• `temperature` T [K] | `speed_of_sound_iso(293.0)  # 343.2`<br><br>• c0 = 343.2 √(T/293) [m/s] |
| `air_density_iso` | `function` | **Air density (ISO 10534-2 Eq. 7).**<br>• `temperature` T [K]<br>• `atmospheric_pressure` [kPa] (Default: 101.325) | `air_density_iso(293.0)  # 1.186`<br><br>• ρ [kg/m³] |
| `speed_of_sound_astm` | `function` | **Speed of sound (ASTM E2611-19 Eq. 4).**<br>• `temperature` T [°C] | `speed_of_sound_astm(20.0)  # 343.2`<br><br>• 20.047 √(273.15 + T) [m/s] |
| `air_density_astm` | `function` | **Air density (ASTM E2611-19 Eq. 5).**<br>• `temperature` T [°C]<br>• `atmospheric_pressure` P [kPa] (Default: 101.325) | `air_density_astm(20.0)  # 1.202`<br><br>• ρ [kg/m³] |
| `characteristic_impedance` | `function` | **Characteristic impedance of air ρc.**<br>• `density` ρ [kg/m³]<br>• `speed_of_sound` c [m/s] | `characteristic_impedance(1.186, 343.2)  # 407.0`<br><br>• [rayl] |
| `tube_attenuation_constant` | `function` | **Lower-bound tube attenuation k0″ (ISO 10534-2 Eq. A.18).**<br>• `frequency` f [Hz]<br>• `speed_of_sound` c0 [m/s]<br>• `diameter` d [m] (hydraulic for rectangular) | `tube_attenuation_constant(1000.0, 343.2, 0.1)  # 0.0179`<br><br>• [Np/m] |
| `tube_wavenumber` | `function` | **Complex wavenumber k0 = k0′ − j k0″ (ISO 10534-2 §2.6).**<br>• `frequency` f [Hz]<br>• `speed_of_sound` c0 [m/s]<br>• `attenuation`: k0″ [Np/m] (Default: None → lossless) | `tube_wavenumber(1000.0, 343.2, attenuation=0.018)`<br><br>• 18.308 − 0.018j [1/m] |
| `mic_calibration_factor` | `function` | **Microphone-mismatch calibration factor Hc (ISO 10534-2 Eq. 10).**<br>• `h12_config1`: H12 in the standard configuration<br>• `h12_config2`: H12 with the microphones interchanged | `hc = mic_calibration_factor(h12_a, h12_b)`<br><br>• Hc = √(H12ᴵ/H12ᴵᴵ) (complex) |
| `apply_mic_calibration` | `function` | **Apply the calibration factor (Eq. 13).**<br>• `h12_uncorrected`<br>• `calibration_factor`: Hc | `h12 = apply_mic_calibration(h12_raw, hc)`<br><br>• H12 = H12,raw/Hc |
| `reflection_factor` | `function` | **Complex reflection factor at the sample surface (ISO 10534-2 Eq. 17).**<br>• `h12`: corrected transfer function<br>• `spacing` s [m]<br>• `x1`: sample to the farther microphone [m]<br>• `wavenumber`: complex k0 | `r = reflection_factor(h12, spacing=0.05, x1=0.15, wavenumber=k0)`<br><br>• r = (H12 − HI)/(HR − H12)·e^(+2jk0x1) |
| `absorption_from_reflection` | `function` | **Normal-incidence absorption coefficient (Eq. 18).**<br>• `reflection`: complex r | `absorption_from_reflection(0.5)  # 0.75`<br><br>• α = 1 − \|r\|² |
| `surface_impedance` | `function` | **Absolute surface impedance Z (Eq. 19).**<br>• `reflection`: complex r<br>• `characteristic_impedance`: ρc0 [rayl] | `surface_impedance(0.5, 407.0)  # 1221`<br><br>• Z = ρc0(1 + r)/(1 − r) [rayl] |
| `normalized_surface_impedance` | `function` | **Normalised surface impedance Z/(ρc0) (Eq. 19).**<br>• `reflection`: complex r | `normalized_surface_impedance(0.5)  # 3.0` |
| `normalized_surface_admittance` | `function` | **Normalised surface admittance Gρc0 (Eq. 20).**<br>• `reflection`: complex r | `normalized_surface_admittance(0.5)  # 0.333`<br><br>• (1 − r)/(1 + r) |
| `two_microphone_impedance` | `function` | **Full two-microphone reduction (ISO 10534-2 Clause 7).**<br>• `h12`: corrected transfer function<br>• `frequency` f [Hz]<br>• `spacing` s [m], `x1` [m]<br>• `speed_of_sound` c0 [m/s], `characteristic_impedance` ρc0 [rayl]<br>• `attenuation`: k0″ [Np/m] (Default: None)<br>• `diameter` + `shape`: activate the plane-wave range check | `res = two_microphone_impedance(h12, frequency=f, spacing=0.05, x1=0.15, speed_of_sound=343.2, characteristic_impedance=407.0)`<br><br>• `ImpedanceTubeResult` |
| `ImpedanceTubeResult` | `dataclass` | **Two-microphone result per frequency.**<br>• `frequency` [Hz]<br>• `reflection`: complex r (Eq. 17)<br>• `surface_impedance` [rayl], `normalized_impedance` (Eq. 19)<br>• `absorption`: α = 1 − \|r\|² (Eq. 18) | `res.absorption, res.normalized_impedance` |
| `ImpedanceTubeWarning` | `warning class` | **ISO 10534-2 advisory.**<br>Emitted by `two_microphone_impedance` for frequencies outside the plane-wave working range (Eqs. 1–4); the results are still returned | `warnings.simplefilter('error', ImpedanceTubeWarning)` |
| `standing_wave_ratio_from_level` | `function` | **Standing-wave ratio from ΔL (ISO 10534-1 Eq. 15).**<br>• `level_difference`: Lmax − Lmin [dB] | `standing_wave_ratio_from_level(20.0)  # 10.0`<br><br>• s = 10^(ΔL/20) |
| `standing_wave_reflection_magnitude` | `function` | **Reflection magnitude from the SWR (Eq. 14).**<br>• `swr`: s ≥ 1 | `standing_wave_reflection_magnitude(10.0)  # 0.818`<br><br>• \|r\| = (s − 1)/(s + 1) |
| `standing_wave_reflection` | `function` | **Complex reflection factor from the standing wave (Eqs. 17–23).**<br>• `swr`: s<br>• `first_min_distance`: x_min1 [m]<br>• `wavelength`: λ0 [m] | `standing_wave_reflection(10.0, 0.05, 0.4)  # -0.818j`<br><br>• phase φ = π(4x_min1/λ0 − 1) |
| `standing_wave_absorption` | `function` | **Absorption from the SWR (Eqs. 9 + 14).**<br>• `swr`: s ≥ 1 | `standing_wave_absorption(10.0)  # 0.331`<br><br>• α = 4s/(s + 1)² |
| `standing_wave_normalized_impedance` | `function` | **Normalised impedance from the standing wave (Eqs. 24–26).**<br>• `swr`, `first_min_distance` [m], `wavelength` [m] | `standing_wave_normalized_impedance(10.0, 0.05, 0.4)`<br><br>• 0.198 − 0.98j |
| `wave_decomposition` | `function` | **Decompose the field into (A, B, C, D) (ASTM E2611-19 Eqs. 17–20).**<br>• `h1`…`h4`: microphone transfer functions<br>• `l1`, `s1`, `l2`, `s2`: geometry [m]<br>• `wavenumber` k | `A, B, C, D = wave_decomposition(h1, h2, h3, h4, l1=0.2, s1=0.05, l2=0.2, s2=0.05, wavenumber=k)`<br><br>• Forward/backward amplitudes, both sides |
| `face_quantities` | `function` | **Face pressures and velocities (Eq. 21).**<br>• `a`, `b`, `c`, `d`: wave amplitudes<br>• `wavenumber` k, `thickness` d [m], `characteristic_impedance` ρc | `p0, pd, u0, ud = face_quantities(A, B, C, D, wavenumber=k, thickness=0.05, characteristic_impedance=407.0)` |
| `transfer_matrix_two_load` | `function` | **Two-load transfer matrix (ASTM E2611-19 Eqs. 17–22).**<br>• `load_a`, `load_b`: (H1, H2, H3, H4) per termination<br>• `l1`, `s1`, `l2`, `s2`, `thickness` [m]<br>• `wavenumber`, `characteristic_impedance` | `T = transfer_matrix_two_load(load_a, load_b, l1=0.2, s1=0.05, l2=0.2, s2=0.05, thickness=0.05, wavenumber=k, characteristic_impedance=407.0)`<br><br>• `TransferMatrix` |
| `transfer_matrix_one_load` | `function` | **One-load transfer matrix, symmetric specimen (Eqs. 23–24).**<br>• `load`: (H1, H2, H3, H4)<br>• Same geometry parameters as `transfer_matrix_two_load` | `T = transfer_matrix_one_load(load, l1=0.2, s1=0.05, l2=0.2, s2=0.05, thickness=0.05, wavenumber=k, characteristic_impedance=407.0)`<br><br>• Requires reciprocity + symmetry |
| `air_layer_transfer_matrix` | `function` | **Analytic loss-free air-layer matrix (validation reference).**<br>• `wavenumber` k<br>• `thickness` d [m]<br>• `characteristic_impedance` ρc [rayl] | `T = air_layer_transfer_matrix(k, 0.05, 407.0)`<br><br>• det(T) = 1, T11 = T22 |
| `TransferMatrix` | `dataclass` | **Acoustic transfer matrix [[T11, T12], [T21, T22]] (ASTM E2611-19 Eq. 16).**<br>• `t11`, `t12`, `t21`, `t22`: complex, scalar or per frequency | `T.t11, T.t12` |
| `airflow_resistance` | `function` | **Airflow resistance R = Δp/qv (ISO 9053-1:2018 §3.1).**<br>• `pressure_drop` Δp [Pa]<br>• `volume_flow_rate` qv [m³/s] | `airflow_resistance(2.0, 0.0005)  # 4000.0`<br><br>• [Pa·s/m³] |
| `specific_airflow_resistance` | `function` | **Specific airflow resistance Rs (§3.2).**<br>• `resistance` R + `area` A, **or** `pressure_drop` Δp + `velocity` u | `specific_airflow_resistance(4000.0, 0.01)  # 40.0`<br><br>• Rs = R·A = Δp/u [Pa·s/m] |
| `airflow_resistivity` | `function` | **Airflow resistivity σ = Rs/d (§3.3).**<br>• `specific_resistance` Rs [Pa·s/m]<br>• `thickness` d [m] | `airflow_resistivity(40.0, 0.05)  # 800.0`<br><br>• [Pa·s/m²] |
| `linear_airflow_velocity` | `function` | **Linear airflow velocity u = qv/A (§3.4).**<br>• `volume_flow_rate` qv [m³/s]<br>• `area` A [m²] | `linear_airflow_velocity(5e-6, 0.01)  # 0.0005`<br><br>• [m/s] |
| `static_airflow_resistance` | `function` | **Stepwise static-method determination (ISO 9053-1 §7.5).**<br>• `velocities` u [m/s], `pressure_drops` Δp [Pa] (≥ 2 steps)<br>• `area` A [m²]<br>• `thickness` d [m] (Default: None → no σ)<br>• `evaluation_velocity` [m/s] (Default: 0.0005) | `res = static_airflow_resistance([0.0005, 0.001, 0.002], [0.02, 0.041, 0.086], 0.01, 0.05)`<br><br>• `StaticAirflowResult` (Rs = 40.0, σ = 800) |
| `StaticAirflowResult` | `dataclass` | **Static-method result at the evaluation velocity.**<br>• `resistance` R [Pa·s/m³], `specific_resistance` Rs [Pa·s/m]<br>• `resistivity` σ [Pa·s/m²] or None<br>• `evaluation_velocity` [m/s], `pressure_drop` [Pa]<br>• `linear_coefficient` a (zero-velocity Rs), `quadratic_coefficient` b | `res.specific_resistance, res.resistivity` |
| `piston_volume_flow_rate` | `function` | **RMS piston volume flow qv = 2πf·h·AP (ISO 9053-2 §6.2).**<br>• `frequency` f [Hz]<br>• `stroke_amplitude` h [m]<br>• `piston_area` AP [m²] | `piston_volume_flow_rate(2.0, 0.005, 0.01)  # 0.000628`<br><br>• [m³/s] |
| `alternating_airflow_resistance` | `function` | **Alternating-method resistance (ISO 9053-2:2020 Formula 2).**<br>• `level_specimen` Lps / `level_termination` Lpt [dB]<br>• `piston_stroke_specimen` hs / `piston_stroke_termination` ht [m]<br>• `frequency` f [Hz] (1–4 Hz), `cavity_volume` V [m³]<br>• `static_pressure` [Pa] (Default: 101325)<br>• `kappa_prime` κ′ (Default: 1.4; use `effective_kappa` for Annex A conformity)<br>• `background_level` Lpb [dB] (Default: None) | `r = alternating_airflow_resistance(78.0, 60.0, piston_stroke_specimen=5e-3, piston_stroke_termination=5e-4, frequency=2.0, cavity_volume=7.854e-4)`<br><br>• R [Pa·s/m³]; `AirflowResistanceWarning` when criteria fail |
| `thermal_boundary_layer_thickness` | `function` | **Thermal boundary-layer thickness b (ISO 9053-2 Formulas A.4/A.5).**<br>• `frequency` f [Hz]<br>• Air properties (Default: Annex A.3 values) | `thermal_boundary_layer_thickness(2.0)  # 0.00183`<br><br>• [m] |
| `effective_kappa` | `function` | **Effective ratio of specific heats κ′ (Formula A.7).**<br>• `cavity_surface` S [m²], `cavity_volume` V [m³]<br>• `frequency` f [Hz]<br>• Air properties (Default: Annex A.3 values) | `effective_kappa(0.0471, 7.854e-4, 2.0)  # 1.37`<br><br>• The Annex A.3 worked example |
| `delany_bazley` | `function` | **Delany–Bazley one-parameter porous model (Mechel 2e G.11; Bies 5e Table D.1).**<br>• `frequency` f [Hz]<br>• `flow_resistivity` σ [Pa·s/m²]<br>• `coefficients`: preset name or (C1…C8) (Default: 'delany_bazley' rockwool/fibreglass)<br>• `speed_of_sound`, `air_density` (Default: 343.0, 1.205)<br>• Warns outside 0.01 < X < 1 (X = ρf/σ) | `med = delany_bazley(f, 20000.0)`<br><br>• `PorousMediumResult` |
| `miki` | `function` | **Miki (1990) positive-real revision of Delany–Bazley.**<br>• `frequency` f [Hz]<br>• `flow_resistivity` σ [Pa·s/m²]<br>• `speed_of_sound`, `air_density`<br>• Stays passive below the fit range; warns outside 0.01 < f/σ < 1 | `med = miki(f, 20000.0)`<br><br>• `PorousMediumResult` |
| `johnson_champoux_allard` | `function` | **JCA five-parameter rigid-frame model (Cox & D'Antonio 3e Eqs. 6.19–6.25).**<br>• `frequency` f [Hz], `flow_resistivity` σ [Pa·s/m²]<br>• `porosity` φ, `tortuosity` T = α∞<br>• `viscous_length` Λ / `thermal_length` Λ′ [m]<br>• Air state: `viscosity`, `prandtl_number`, `heat_capacity_ratio`, `atmospheric_pressure` | `med = johnson_champoux_allard(f, 20000.0, porosity=0.98, tortuosity=1.05, viscous_length=8.7e-5, thermal_length=1.7e-4)`<br><br>• `PorousMediumResult` |
| `PorousMediumResult` | `dataclass` | **Equivalent-fluid characterisation.**<br>• `characteristic_impedance` Zc [Pa·s/m], `wavenumber` k [rad/m] (Im < 0)<br>• `effective_density`, `bulk_modulus` (surface-normalised)<br>• `.normalized_impedance`, `.normalized_wavenumber`<br>• `.plot()`: normalised Zc/k components | `med.characteristic_impedance, med.plot()` |
| `DELANY_BAZLEY_COEFFICIENTS` | `mapping` | **Bies 5e Table D.1 coefficient presets (C1…C8).**<br>'delany_bazley' (rockwool/fibreglass), 'garai_pompoli' (polyester), 'dunn_davern' / 'wu' (foams) | `DELANY_BAZLEY_COEFFICIENTS['garai_pompoli']` |
| `DELANY_BAZLEY_VALIDITY` | `tuple` | **Stated fit range in X = ρf/σ.**<br>(0.01, 1.0) | `DELANY_BAZLEY_VALIDITY  # (0.01, 1.0)` |
| `MIKI_VALIDITY` | `tuple` | **Miki fit range in f/σ.**<br>(0.01, 1.0) | `MIKI_VALIDITY  # (0.01, 1.0)` |
| `layered_absorber` | `function` | **Transfer-matrix multilayer prediction at one angle (Cox & D'Antonio Eq. 2.29; Bies Eq. D.95; Mechel D.4).**<br>• `frequency` f [Hz]<br>• `layers`: stack from the incidence side (`AirLayer`, `PorousLayer`, `PerforatedPlateLayer`, `MicroperforatedPlateLayer`, `MembraneLayer`)<br>• `angle` θ [rad] (Default: 0)<br>• `termination`: 'rigid'/'free'/complex Z [Pa·s/m] (Default: 'rigid')<br>• `speed_of_sound`, `air_density`, `viscosity` | `res = layered_absorber(f, [PorousLayer(0.05, med)])`<br><br>• `LayeredAbsorberResult` |
| `LayeredAbsorberResult` | `dataclass` | **Oblique-incidence prediction.**<br>• `surface_impedance` Zs, `normalized_impedance`<br>• `reflection` R(θ), `absorption` α(θ)<br>• `transfer_matrix`: (2, 2, n) chain matrix (det = 1)<br>• `.plot()`: α(f) with \|R\| overlaid | `res.absorption, res.surface_impedance` |
| `diffuse_field_absorption` | `function` | **Random-incidence (Paris) absorption (Mechel D.5 Eq. 9).**<br>• `frequency`, `layers`, `termination` as `layered_absorber`<br>• `angle_limit` [rad] (Default: π/2; 75°–87° truncations in use)<br>• `quadrature_points` (Default: 64, Gauss–Legendre) | `dif = diffuse_field_absorption(f, layers)`<br><br>• `DiffuseFieldAbsorptionResult` |
| `DiffuseFieldAbsorptionResult` | `dataclass` | **Paris-integral result.**<br>• `absorption`: α_dif(f)<br>• `angle_limit` [rad]<br>• `.plot()` | `dif.absorption` |
| `statistical_absorption` | `function` | **Closed-form Paris integral, locally reacting plane (Mechel D.5 Eq. 10).**<br>• `normalized_impedance` z = Zs/(ρc) (Re z > 0)<br>• `angle_limit` [rad] (Default: π/2)<br>• Maximum over passive z is the published 0.951 | `statistical_absorption(1.567 + 0j)  # 0.951` |
| `AirLayer` | `dataclass` | **Plain air gap.**<br>• `thickness` d [m] (0 allowed → transparent) | `AirLayer(0.05)` |
| `PorousLayer` | `dataclass` | **Porous layer.**<br>• `thickness` d [m]<br>• `medium`: `PorousMediumResult` on the solver frequency grid | `PorousLayer(0.05, med)` |
| `PerforatedPlateLayer` | `dataclass` | **Rigid perforated plate.**<br>• `thickness` t, `hole_radius` a [m]<br>• `open_area` ε (0–1)<br>• `end_correction` δ per end (Default: None → Fok/Nesterov of ε) | `PerforatedPlateLayer(0.006, 0.0025, 0.05)` |
| `MicroperforatedPlateLayer` | `dataclass` | **Maa microperforated plate.**<br>• `thickness` t, `hole_radius` a [m] (submillimetre)<br>• `open_area` ε<br>• `end_correction` δ (Default: 0.85) | `MicroperforatedPlateLayer(0.0002, 0.0001, 0.005)` |
| `MembraneLayer` | `dataclass` | **Limp impervious membrane.**<br>• `surface_density` m [kg/m²]<br>• `resistance` r [Pa·s/m] (Default: 0) | `MembraneLayer(5.0)` |
| `perforated_plate_impedance` | `function` | **Perforated-plate transfer impedance (Cox & D'Antonio Eqs. 7.6/7.12).**<br>• `frequency`, `thickness`, `hole_radius`, `open_area`, `end_correction`<br>• `air_density`, `viscosity` | `z = perforated_plate_impedance(f, thickness=0.006, hole_radius=0.0025, open_area=0.05)`<br><br>• complex [Pa·s/m] |
| `microperforated_plate_impedance` | `function` | **Maa's exact MPP impedance (Cox & D'Antonio Eqs. 7.33–7.35).**<br>• `frequency`, `thickness`, `hole_radius`, `open_area`<br>• `end_correction` δ (Default: 0.85)<br>• Bessel circular-capillary kernel | `z = microperforated_plate_impedance(f, thickness=0.0002, hole_radius=0.0001, open_area=0.005)`<br><br>• complex [Pa·s/m] |
| `membrane_impedance` | `function` | **Membrane transfer impedance r + jωm (Cox Eq. 7.14; Bies Eq. D.96).**<br>• `frequency`, `surface_density` m [kg/m²]<br>• `resistance` r (Default: 0) | `z = membrane_impedance(f, surface_density=5.0)`<br><br>• complex [Pa·s/m] |
| `perforation_end_correction` | `function` | **End-correction factor δ(ε) = 0.85(1 − 1.47√ε + 0.47ε^1.5) (Cox Table 7.1).**<br>• `open_area` ε | `perforation_end_correction(0.05)  # 0.575`<br><br>• per orifice end |
| `helmholtz_resonance_frequency` | `function` | **Shallow-cavity perforate resonance (Cox Eq. 7.4).**<br>• `cavity_depth` d, `plate_thickness` t, `hole_radius` a [m]<br>• `open_area` ε, `end_correction` δ<br>• `speed_of_sound` | `helmholtz_resonance_frequency(cavity_depth=0.025, plate_thickness=0.006, hole_radius=0.0025, open_area=0.05)`<br><br>• f0 = (c/2π)√(ε/(t′d)) [Hz] |
| `membrane_resonance_frequency` | `function` | **Membrane mass-spring resonance (Cox Eqs. 7.9/7.10).**<br>• `surface_density` m [kg/m²], `cavity_depth` d [m]<br>• `isothermal` (Default: False)<br>• `speed_of_sound`, `air_density` | `membrane_resonance_frequency(surface_density=5.0, cavity_depth=0.05)  # ≈120`<br><br>• ≈ 60/√(md) (50/√(md) isothermal) [Hz] |
| `PorousAbsorberWarning` | `warning class` | **Porous-model advisory.**<br>Emitted when the Delany–Bazley / Miki regressions are evaluated outside their published fit range; values are still returned | `warnings.simplefilter('error', PorousAbsorberWarning)` |
| `slit_helmholtz_absorber` | `function` | **Slit panel loaded with Helmholtz resonators (Jiménez et al. Appl. Sci. 2017).**<br>• `frequency` f [Hz], `resonators`: one/many `HelmholtzResonator`<br>• `slit_height` h, `lattice_step` a, `period` d [m]<br>• `angle` θ [rad] (Default: 0)<br>• `end_correction`, `slit_radiation` (Default: True)<br>• visco-thermal slit + square-duct losses | `res = slit_helmholtz_absorber(f, hr, slit_height=1e-3, lattice_step=3e-2, period=5e-2)`<br><br>• `SlitResonatorAbsorberResult` |
| `SlitResonatorAbsorberResult` | `dataclass` | **Slow-sound panel prediction.**<br>• `surface_impedance` Z = T11/T21, `normalized_impedance`<br>• `reflection` R(θ), `absorption` α = 1 − \|R\|²<br>• `effective_wavenumber`, `effective_impedance`<br>• `transfer_matrix`: (2, 2, n)<br>• `.plot()`: α(f) with \|R\| overlaid | `res.absorption, res.plot()` |
| `critical_coupling_design` | `function` | **Solve geometry for perfect absorption at a frequency (critical coupling, Eq. 9).**<br>• `target_frequency` f0 [Hz], base `resonator`<br>• `lattice_step` a, `period` d [m], `angle` θ<br>• tunes cavity length + slit height so the reflection zero is real<br>• warns if α does not reach ≈1 | `d = critical_coupling_design(300.0, hr, lattice_step=3e-2, period=5e-2)`<br><br>• `CriticalCouplingResult` |
| `CriticalCouplingResult` | `dataclass` | **Perfect-absorption design outcome.**<br>• `resonator`, `slit_height`: solved geometry<br>• `absorption` (≈1), `normalized_impedance` (≈1)<br>• `converged`, `target_frequency`, `angle` | `d.resonator, d.slit_height, d.absorption` |
| `HelmholtzResonator` | `dataclass` | **Square-cross-section Helmholtz resonator.**<br>• `neck_length` l_n, `neck_side` w_n [m]<br>• `cavity_length` l_c, `cavity_side` w_c [m] | `HelmholtzResonator(1e-3, 3e-3, 30e-3, 27e-3)` |
| `helmholtz_resonator_impedance` | `function` | **Resonator acoustic impedance Z_HR with visco-thermal losses (Jiménez et al. APL 2016 Eq. A23).**<br>• `frequency`, `resonator`<br>• `slit_height`, `lattice_step` for neck→slit end correction<br>• `end_correction` (Default: True) | `z = helmholtz_resonator_impedance(f, hr)`<br><br>• complex [Pa·s/m³] |
| `slit_effective_properties` | `function` | **Narrow-slit visco-thermal ρ_s, κ_s (Stinson 1991; Eq. 6).**<br>• `frequency`, `slit_height` h [m]<br>• air state kwargs<br>• limit jωρ_s → 12η/h² as ω → 0 | `rho_s, kap_s = slit_effective_properties(f, slit_height=1.2e-3)` |
| `rectangular_duct_properties` | `function` | **Square-duct visco-thermal ρ, κ (Stinson 1991; Eqs. 7–8).**<br>• `frequency`, `side` [m]<br>• `sum_terms` (Default: 40)<br>• limit jωρ → 28.454η/side² as ω → 0 | `rho, kap = rectangular_duct_properties(f, side=3e-3)` |
| `SlowSoundAbsorberWarning` | `warning class` | **Slow-sound absorber advisory.**<br>Emitted when `critical_coupling_design` cannot reach perfect absorption within tolerance | `warnings.simplefilter('error', SlowSoundAbsorberWarning)` |
| `AirflowResistanceWarning` | `warning class` | **ISO 9053 advisory.**<br>Emitted for a velocity above the 15 mm/s static limit, a piston frequency outside 1–4 Hz, or a failed Formula 3/4 validity criterion | `warnings.simplefilter('error', AirflowResistanceWarning)` |
| `practical_absorption_coefficient` | `function` | **Practical coefficients αp (ISO 11654 §4.1).**<br>• `third_octave_alpha_s`: 15 one-third-octave αs, 200–5000 Hz (sequence or mapping keyed by band) | `ap = practical_absorption_coefficient(alpha_s)`<br><br>• 5 octave values (250–4000 Hz), 0.05 steps, capped at 1.00 |
| `weighted_absorption` | `function` | **Weighted absorption αw + class (ISO 11654 §4.2).**<br>• `alpha_p`: the 5 octave practical coefficients | `w = weighted_absorption([0.25, 0.7, 0.95, 1.0, 0.85])`<br><br>• `AbsorptionRatingResult` (αw = 0.55, class D, 'MH') |
| `weighted_absorption_from_third_octave` | `function` | **αw from 15 one-third-octave αs, retaining them for the fiche (ISO 11654 §4.1–4.2).**<br>• `third_octave_alpha_s`: 15 αs, 200–5000 Hz (sequence or mapping keyed by band) | `w = weighted_absorption_from_third_octave(alpha_s)`<br><br>• `AbsorptionRatingResult` carrying `third_octave_alpha_s` / `third_octave_bands` |
| `absorption_class` | `function` | **Sound absorption class (Table B.1).**<br>• `alpha_w`: multiple of 0.05 in [0, 1] | `absorption_class(0.7)  # 'C'`<br><br>• 'A'–'E' or 'Not classified' |
| `AbsorptionRatingResult` | `dataclass` | **αw rating result.**<br>• `alpha_w`: shifted curve read at 500 Hz<br>• `shape_indicator`: 'L'/'M'/'H' concatenation or ''<br>• `absorption_class`: 'A'–'E'/'Not classified'<br>• `shift`, `unfavourable_sum` (≤ 0.10)<br>• `band_centers` [Hz], `measured`, `shifted_reference`<br>• `.plot()` | `w.alpha_w, w.absorption_class` |
| `OCTAVE_BANDS` | `tuple` | **ISO 11654 octave rating bands [Hz].**<br>(250, 500, 1000, 2000, 4000) | `OCTAVE_BANDS  # (250, ..., 4000)` |
| `THIRD_OCTAVE_BANDS` | `tuple` | **ISO 11654 one-third-octave input bands [Hz].**<br>200 Hz to 5000 Hz (15 bands) | `THIRD_OCTAVE_BANDS[0]  # 200` |
| `REFERENCE_CURVE` | `mapping` | **ISO 11654 Figure 1 reference curve (read-only).**<br>Keyed by octave band [Hz] → coefficient | `REFERENCE_CURVE[250]  # 0.8` |
| `speed_of_sound` | `function` | **Speed of sound (ISO 17497-1 Eq. 2).**<br>• `temperature` t [°C] | `speed_of_sound(20.0)  # 343.2`<br><br>• 343.2 √((273.15 + t)/293.15) [m/s] |
| `air_attenuation_coefficient` | `function` | **Energy attenuation m from ISO 9613-1 α (ISO 17497-1 Eq. 3).**<br>• `pressure_attenuation_db_per_m`: α [dB/m] | `air_attenuation_coefficient(0.005)  # 0.00115`<br><br>• m ≈ α/4.343 [1/m] |
| `random_incidence_absorption` | `function` | **Random-incidence absorption αs (ISO 17497-1 Eq. 1).**<br>• `volume` V [m³], `area` S [m²]<br>• `c1`/`T1`: static base plate, no sample<br>• `c2`/`T2`: with the test sample<br>• `m1`/`m2`: air attenuation [1/m] (Default: 0) | `a_s = random_incidence_absorption(200.0, 10.8, c1=343.0, T1=2.5, c2=343.2, T2=1.8)  # 0.463` |
| `specular_absorption_coefficient` | `function` | **Specular absorption αspec (Eq. 4).**<br>• `volume`, `area`<br>• `c3`/`T3`: rotating base plate, no sample<br>• `c4`/`T4`: sample on the rotating turntable<br>• `m3`/`m4` (Default: 0) | `a_spec = specular_absorption_coefficient(200.0, 10.8, c3=c3, T3=t3, c4=c4, T4=t4)`<br><br>• Includes the energy lost to scattering |
| `scattering_coefficient` | `function` | **Random-incidence scattering coefficient s (Eq. 5).**<br>• `alpha_spec`, `alpha_s`<br>• `truncate_negative`: clip s < 0 to 0 (Default: True); s > 1 is kept | `scattering_coefficient(0.45, 0.30)  # 0.214`<br><br>• s = (αspec − αs)/(1 − αs) |
| `scattering_coefficient_spectrum` | `function` | **Scattering spectrum s(f) (Eq. 5).**<br>• `frequencies`: one-third-octave centres [Hz]<br>• `specular_absorption`, `random_absorption`: per band<br>• `truncate_negative` (Default: True) | `res = scattering_coefficient_spectrum(f, a_spec, a_s)`<br><br>• `ScatteringResult` |
| `ScatteringResult` | `dataclass` | **Scattering spectrum result.**<br>• `frequencies` [Hz]<br>• `scattering`: s per band<br>• `random_incidence`: αs, `specular`: αspec<br>• `.plot()` | `res.scattering` |
| `base_plate_scattering` | `function` | **Scattering of the base plate alone (Eq. 6).**<br>• `volume`, `area`<br>• `c1`/`T1`: static, `c3`/`T3`: rotating<br>• `m1`/`m3` (Default: 0) | `s_base = base_plate_scattering(200.0, 10.8, c1=c1, T1=t1, c3=c3, T3=t3)`<br><br>• Quality metric vs Table 1 |
| `check_base_plate_scattering` | `function` | **Verify the base plate against Table 1 (Clause 6.2).**<br>• `scattering`: mapping by band [Hz] or 18 values ordered as `BASE_PLATE_BANDS` | `check_base_plate_scattering(s_base)  # ()`<br><br>• Offending band centres; `ScatteringDiffusionWarning` if any |
| `BASE_PLATE_BANDS` | `tuple` | **ISO 17497-1 base-plate check bands [Hz].**<br>100 Hz to 5000 Hz (18 one-third octaves) | `BASE_PLATE_BANDS[0]  # 100` |
| `BASE_PLATE_MAX_SCATTERING` | `mapping` | **Table 1 base-plate scattering limits (read-only).**<br>Keyed by band [Hz] → maximum s | `BASE_PLATE_MAX_SCATTERING[500]  # 0.05` |
| `reverberation_time_uncertainty` | `function` | **Standard uncertainty of a mean T (ISO 17497-1 Eq. A.1).**<br>• `times`: N ≥ 2 spatially-averaged measurements [s] | `reverberation_time_uncertainty([2.31, 2.28, 2.35])  # 0.0203`<br><br>• Standard error of the mean [s] |
| `absorption_coefficient_uncertainty` | `function` | **Uncertainty of a Sabine coefficient (Eqs. A.3/A.4).**<br>• `volume`, `area`<br>• `c` [m/s]<br>• `T_a`/`u_a`, `T_b`/`u_b`: the two situations [s] | `u_alpha = absorption_coefficient_uncertainty(200.0, 10.8, c=343.2, T_a=2.5, u_a=0.02, T_b=1.8, u_b=0.02)` |
| `scattering_coefficient_uncertainty` | `function` | **Uncertainty of the scattering coefficient (Eq. A.5).**<br>• `alpha_spec`, `alpha_s`<br>• `u_alpha_spec`, `u_alpha_s` | `u = scattering_coefficient_uncertainty(0.45, 0.30, 0.02, 0.02)`<br><br>• `ScatteringUncertainty` (U = 2u, 95 %) |
| `ScatteringUncertainty` | `dataclass` | **Scattering uncertainty result.**<br>• `u_scattering`: combined standard u_s<br>• `expanded`: U = 2u_s | `u.u_scattering, u.expanded` |
| `directional_diffusion_coefficient` | `function` | **Directional diffusion coefficient d_θ (ISO 17497-2 Formulas 5/6).**<br>• `levels`: n ≥ 2 reflected SPL [dB] (−inf = zero energy)<br>• `area_weights`: Ni (Formula 8) (Default: None → equal-area Formula 5) | `directional_diffusion_coefficient([-10., -12., -15., -11., -13.])  # 0.854` |
| `directional_diffusion` | `function` | **Polar response + its coefficient.**<br>• `angles` [°], `levels` [dB]<br>• `weights`: Ni (Default: None) | `res = directional_diffusion(angles, levels)`<br><br>• `DiffusionResult` |
| `DiffusionResult` | `dataclass` | **Polar diffusion result.**<br>• `angles` [°], `levels` [dB]<br>• `coefficient`: d_θ (autocorrelation)<br>• `.plot()`, `.report()` | `res.coefficient` |
| `diffusion_spectrum` | `function` | **Diffusion spectrum d(f) (ISO 17497-2 Clause 8.5).**<br>• `frequencies`: one-third-octave centres [Hz]<br>• `diffusion`: d per band (directional, or random-incidence via Clause 8.4)<br>• `normalized`: d_n per band (Default: None) | `res = diffusion_spectrum(f, d, normalized=d_n)`<br><br>• `DiffusionSpectrum` |
| `DiffusionSpectrum` | `dataclass` | **Diffusion spectrum result.**<br>• `frequencies` [Hz]<br>• `diffusion`: d per band<br>• `normalized`: d_n per band or None<br>• `.plot()`, `.report()` | `res.diffusion` |
| `normalized_diffusion_coefficient` | `function` | **Normalised coefficient d_θ,n (Formula 7).**<br>• `d_theta`: test surface<br>• `d_theta_reference`: flat reference | `normalized_diffusion_coefficient(0.6, 0.2)  # 0.5`<br><br>• (d − d_r)/(1 − d_r) |
| `area_factors` | `function` | **Per-receiver area weights Ni (Clause 8.3 Formula 8).**<br>• `elevations`: θ [°], 0–90<br>• `delta_theta` [°]<br>• `delta_phi` [°] (Default: None → `delta_theta`) | `n_i = area_factors([0, 15, 30, 45, 60, 75, 90], delta_theta=15.0)`<br><br>• Dimensionless, min 1 |
| `random_incidence_diffusion` | `function` | **Random-incidence diffusion coefficient d (Clause 8.4).**<br>• `directional_coefficients`: d_θ per source<br>• `weights`: source weights (Default: None → equal; 2-D uses `TWO_DIMENSIONAL_SOURCE_WEIGHTS`) | `d = random_incidence_diffusion(d_thetas, weights=TWO_DIMENSIONAL_SOURCE_WEIGHTS)` |
| `TWO_DIMENSIONAL_SOURCE_WEIGHTS` | `tuple` | **ISO 17497-2 single-plane source weights.**<br>(1, 3, 3, 3, 3) for 0°, ±30°, ±60° | `TWO_DIMENSIONAL_SOURCE_WEIGHTS` |
| `ScatteringDiffusionWarning` | `warning class` | **ISO 17497 advisory.**<br>Emitted for out-of-range scattering/diffusion measurement conditions (e.g. a base plate over the Table 1 limits) | `warnings.simplefilter('error', ScatteringDiffusionWarning)` |
| `quadratic_residue_sequence` | `function` | **Quadratic residue sequence s_n (Cox & D'Antonio Eq. 10.2).**<br>• `prime`: odd prime generator N | `quadratic_residue_sequence(7)  # [0 1 4 2 2 4 1]`<br><br>• s_n = n² mod N |
| `qrd_well_depths` | `function` | **QRD well depths d_n (Cox & D'Antonio Eq. 10.3).**<br>• `prime`: generator N<br>• `design_frequency` f0 [Hz]<br>• `speed_of_sound` c [m/s] (Default: 343) | `qrd_well_depths(7, 500.0)  # d_max 0.196 m`<br><br>• d_n = s_n λ0/(2N) |
| `predict_diffuser_polar_response` | `function` | **Predicted far-field polar response (Cox & D'Antonio Eq. 5.8).**<br>• `well_width` w [m], `frequency` f [Hz]<br>• `depths` d_n [m] or `reflection` R_n (exactly one)<br>• `angles` [°] (Default: semicircle), `source_angle` ψ [°]<br>• `periods` Np (Default: 1) | `s = predict_diffuser_polar_response(0.10, 2000.0, depths=d, periods=5)`<br><br>• `DiffuserPolarResponse` |
| `predicted_diffusion_spectrum` | `function` | **Predicted diffusion spectrum d(f) of a design.**<br>• `well_width` [m], `frequencies` [Hz]<br>• `depths` d_n [m]<br>• `periods` (Default: 1)<br>• `normalize`: also d_n vs flat reference (Default: True) | `res = predicted_diffusion_spectrum(0.10, f, depths=d, periods=5)`<br><br>• `DiffusionSpectrum` |
| `DiffuserPolarResponse` | `dataclass` | **Predicted diffuser polar response.**<br>• `frequency` [Hz]<br>• `angles` [°], `levels` [dB] (peak at 0)<br>• `coefficient`: d_θ<br>• `.plot()` | `s.coefficient` |
| `DEFAULT_POLAR_ANGLES` | `tuple` | **ISO 17497-2 single-plane receiver angles.**<br>−90° to 90° in 5° steps (37 receivers) | `DEFAULT_POLAR_ANGLES[0]  # -90` |
| `adrienne_window` | `function` | **Adrienne temporal window (ISO 13472-1 Clause 6.4).**<br>• `fs` [Hz]<br>• `flat_duration` [s] (Default: 0.005)<br>• `leading_duration` [s] (Default: 0.0005), `trailing_duration` [s] (Default: 0.005)<br>• `leading_edge`/`trailing_edge`: 'blackman-harris' or 'cosine-squared' | `w = adrienne_window(48000)`<br><br>• Rising edge + flat top + falling edge, peak 1.0 |
| `geometric_spreading_factor` | `function` | **Geometrical-spreading factor Kr (Clause 4.1).**<br>• `source_height` ds [m] (Default: 1.25)<br>• `mic_height` dm [m] (Default: 0.25) | `geometric_spreading_factor()  # 0.6667`<br><br>• Kr = (ds − dm)/(ds + dm) |
| `geometric_spreading_factor_angle` | `function` | **Oblique factor Kr,θ (Annex F).**<br>• `incidence_angle` θ [rad]<br>• `source_height`, `mic_height` | `geometric_spreading_factor_angle(np.pi/6)  # 0.764` |
| `reflected_path_delay` | `function` | **Reflected-path delay Δτ = 2dm/c (Annex C).**<br>• `mic_height` dm [m] (Default: 0.25)<br>• `speed_of_sound` c [m/s] (Default: 340.0) | `reflected_path_delay()  # 0.001471`<br><br>• [s] |
| `insitu_reflection_factor` | `function` | **Complex reflection factor r(f) (ISO 13472-1 Clause 4.1).**<br>• `incident_ir` / `reflected_ir`: windowed impulse responses<br>• `source_height`, `mic_height` [m]<br>• `incidence_angle` [rad] (Default: 0)<br>• `fs` + `delay`: undo the reflected-path offset (Default: None)<br>• `n`: FFT length | `r = insitu_reflection_factor(hi, hr, fs=48000, delay=dtau)`<br><br>• (1/Kr)·Hr(f)/Hi(f) at the rfft bins |
| `insitu_absorption_from_reflection` | `function` | **α from the reflection factor (Clause 4.1).**<br>• `reflection`: complex r | `alpha = insitu_absorption_from_reflection(r)`<br><br>• α = 1 − \|r\|² |
| `power_reflection_coefficient` | `function` | **Power reflection factor QW(f), direct energy route.**<br>• `incident_ir`, `reflected_ir`<br>• `source_height`, `mic_height`, `incidence_angle`, `n` | `qw = power_reflection_coefficient(hi, hr)`<br><br>• (1/Kr²)\|Hr/Hi\|², offset-independent |
| `insitu_absorption_coefficient` | `function` | **Narrow-band absorption α(f) (Clause 4.1).**<br>• Same parameters as `power_reflection_coefficient` | `alpha = insitu_absorption_coefficient(hi, hr)`<br><br>• α = 1 − QW(f) |
| `one_third_octave_absorption` | `function` | **Aggregate narrow-band α into one-third octaves.**<br>• `frequency` [Hz], `absorption`<br>• `f_min` (Default: 250), `f_max` (Default: 4000; 1600 for Part 2) [Hz]<br>• `clip_negative` (Default: True) | `fc, ab = one_third_octave_absorption(f, alpha)`<br><br>• Band centres + linear-averaged α (nan if empty) |
| `insitu_absorption_spectrum` | `function` | **End-to-end in-situ spectrum (ISO 13472-1).**<br>• `incident_ir`, `reflected_ir`, `fs` [Hz]<br>• geometry + `incidence_angle`<br>• `f_min`/`f_max` [Hz], `clip_negative` | `res = insitu_absorption_spectrum(hi, hr, 48000)`<br><br>• `InsituAbsorptionResult` |
| `InsituAbsorptionResult` | `dataclass` | **In-situ absorption spectrum.**<br>• `frequencies`: one-third-octave centres [Hz]<br>• `absorption`: α per band (nan when empty)<br>• `.plot()` | `res.absorption` |
| `absorption_reference_corrected` | `function` | **Reference-corrected road absorption (Annex B).**<br>• `road_reflection` / `reference_reflection`: measured Qp (complex, same geometry) | `alpha = absorption_reference_corrected(q_road, q_ref)`<br><br>• 1 − \|Qp,road/Qp,ref\|²; removes chain error and Kr |
| `max_sampled_area_radius` | `function` | **Maximum sampled-area radius (Annex A).**<br>• `window_width` Tw [s]<br>• `source_height`, `mic_height`, `speed_of_sound` | `max_sampled_area_radius(0.005)  # 1.34`<br><br>• [m], the Annex A worked example |
| `msa_major_axis` | `function` | **Major axis of the oblique sampled ellipsoid (Annex F).**<br>• `window_width` Tw [s]<br>• `projected_distance` dp [m]<br>• `source_height`, `mic_height`, `speed_of_sound` | `msa_major_axis(0.005, 0.0)  # 3.2`<br><br>• a = cTw + √((ds + dm)² + dp²) [m] |
| `spot_tube_upper_frequency` | `function` | **Spot-tube upper frequency (ISO 13472-2 §5.4.1).**<br>• `diameter` d [m]<br>• `speed_of_sound` (Default: 340.0) | `spot_tube_upper_frequency(0.1)  # 1972`<br><br>• f_u = 0.58c0/d [Hz] |
| `spot_microphone_spacing_bounds` | `function` | **Spacing bounds (s_min, s_max) (§5.4.2).**<br>• `speed_of_sound` (Default: 340.0)<br>• `f_min` (Default: 220), `f_max` (Default: 1800) [Hz] | `spot_microphone_spacing_bounds()  # (0.0773, 0.085)`<br><br>• [m], brackets the nominal 81 mm |
| `check_spot_frequency_range` | `function` | **Advise outside 250–1600 Hz (ISO 13472-2 Scope).**<br>• `frequency` [Hz] | `check_spot_frequency_range(f)`<br><br>• `RoadAbsorptionWarning` out of range |
| `spot_internal_loss_correction` | `function` | **Internal-loss (system) correction (Annex A).**<br>• `measured_absorption`, `system_absorption`: same bands<br>• `clip_negative` (Default: True) | `spot_internal_loss_correction([0.12, 0.10], [0.02, 0.03])  # [0.1, 0.07]`<br><br>• Subtractive Part-2 correction |
| `DEFAULT_SOURCE_HEIGHT` | `float` | **ISO 13472-1 mandatory source height ds [m].**<br>1.25 | `DEFAULT_SOURCE_HEIGHT  # 1.25` |
| `DEFAULT_MIC_HEIGHT` | `float` | **ISO 13472-1 mandatory microphone height dm [m].**<br>0.25 | `DEFAULT_MIC_HEIGHT  # 0.25` |
| `DEFAULT_SPEED_OF_SOUND` | `float` | **Default speed of sound for the road methods [m/s].**<br>340.0 | `DEFAULT_SPEED_OF_SOUND  # 340.0` |
| `PART1_FREQUENCY_RANGE` | `tuple` | **ISO 13472-1 valid band range [Hz].**<br>(250.0, 4000.0) | `PART1_FREQUENCY_RANGE` |
| `SPOT_FREQUENCY_RANGE` | `tuple` | **ISO 13472-2 valid band range [Hz].**<br>(250.0, 1600.0) | `SPOT_FREQUENCY_RANGE` |
| `SPOT_NARROW_BAND_RANGE` | `tuple` | **ISO 13472-2 narrow-band range [Hz].**<br>(220.0, 1800.0) | `SPOT_NARROW_BAND_RANGE` |
| `RoadAbsorptionWarning` | `warning class` | **ISO 13472 advisory.**<br>Emitted for frequencies outside the valid in-situ road-absorption ranges; results there are advisory | `warnings.simplefilter('error', RoadAbsorptionWarning)` |
| `frequency_weighting` | `function` | **Vibration frequency weighting H(f) (ISO 8041-1:2017 Formula 5).**<br>• `name`: one of `WEIGHTING_NAMES`<br>• `frequencies` [Hz] (> 0) | `wr = frequency_weighting('Wk', [1.0, 8.0, 63.0])`<br><br>• `WeightingResponse`; \|H\| = [0.482, 1.036, 0.186] |
| `weighting_factors` | `function` | **Weighting factors \|H(f)\| (the Wi of ISO 2631-1 Eq. 9).**<br>• `name`, `frequencies` [Hz] | `weighting_factors('Wh', [16.0])`<br><br>• Magnitude per frequency |
| `apply_weighting` | `function` | **Apply a weighting to a time signal (frequency domain, exact response).**<br>• `signal`: acceleration (1D) [m/s²]<br>• `fs` [Hz]<br>• `name`: one of `WEIGHTING_NAMES` | `aw_t = apply_weighting(a, fs, 'Wk')`<br><br>• Weighted signal, same length (circular FFT filtering) |
| `WeightingResponse` | `dataclass` | **Weighting response.**<br>• `name`<br>• `frequencies` [Hz]<br>• `response`: complex H<br>• `magnitude`, `magnitude_db` [dB]<br>• `.plot()` | `wr.magnitude, wr.magnitude_db` |
| `WEIGHTING_NAMES` | `tuple` | **ISO 8041-1 weighting names.**<br>('Wb', 'Wc', 'Wd', 'We', 'Wf', 'Wh', 'Wj', 'Wk', 'Wm') | `'Wk' in WEIGHTING_NAMES  # True` |
| `weighted_acceleration` | `function` | **Weighted r.m.s. from a band spectrum (ISO 2631-1 Eq. 9 / ISO 5349-1 Eq. A.1).**<br>• `band_accelerations`: ai per band [m/s²]<br>• `frequencies`: band centres [Hz]<br>• `weighting`: name | `ws = weighted_acceleration([0.1, 0.3, 0.2], [4.0, 8.0, 16.0], 'Wk')`<br><br>• `WeightedSpectrum`; aw = 0.36 m/s² |
| `WeightedSpectrum` | `dataclass` | **Weighted band spectrum.**<br>• `frequencies` [Hz], `band_accelerations` [m/s²]<br>• `weighting_name`, `weighting_factors`<br>• `weighted`: Wi·ai [m/s²]<br>• `overall`: aw [m/s²]<br>• `.plot()` | `ws.overall` |
| `running_rms` | `function` | **Running r.m.s. of a weighted signal (ISO 2631-1 Eqs. 2/3).**<br>• `signal` [m/s²], `fs` [Hz]<br>• `integration_time` τ [s] (Default: 1.0)<br>• `method`: 'linear' (Eq. 2) or 'exponential' (Eq. 3) (Default: 'linear') | `env = running_rms(aw_t, fs)`<br><br>• aw(t0) per sample [m/s²] |
| `mtvv` | `function` | **Maximum transient vibration value (Eq. 4).**<br>• `signal` [m/s²], `fs` [Hz]<br>• `integration_time` [s] (Default: 1.0) | `mtvv(aw_t, fs)`<br><br>• max aw(t0) [m/s²] |
| `vibration_dose_value` | `function` | **Vibration dose value VDV (Eq. 5).**<br>• `signal`: weighted acceleration [m/s²]<br>• `fs` [Hz] | `vdv = vibration_dose_value(aw_t, fs)`<br><br>• (∫aw⁴dt)^(1/4) [m/s^1.75] |
| `motion_sickness_dose_value` | `function` | **Motion sickness dose value MSDV (ISO 2631-1 clause 9).**<br>• `signal`: Wf-weighted acceleration [m/s²]<br>• `fs` [Hz] | `msdv = motion_sickness_dose_value(aw_t, fs)`<br><br>• (∫aw²dt)^(1/2) [m/s^1.5] |
| `crest_factor` | `function` | **Crest factor of a weighted signal (clause 6.2.1).**<br>• `signal` [m/s²] | `crest_factor(aw_t)`<br><br>• peak/r.m.s.; `HumanVibrationWarning` above 9 |
| `vibration_total_value` | `function` | **Vibration total value av/ahv (ISO 2631-1 Eq. 10).**<br>• `components`: axis-weighted r.m.s. [m/s²]<br>• `k`: per-axis factors (Default: None → 1, the ISO 5349-1 vector sum) | `vibration_total_value([0.3, 0.4, 0.5], k=[1.4, 1.4, 1.0])  # 0.86`<br><br>• √(Σ kj²awj²) [m/s²] |
| `wbv_exposure_basis` | `function` | **Whole-body A(8) basis of Directive 2002/44/EC (Annex Part B).**<br>• `a_wx`, `a_wy`: Wd-weighted axis r.m.s. [m/s²]<br>• `a_wz`: Wk-weighted axis r.m.s. [m/s²] | `wbv_exposure_basis(0.35, 0.28, 0.62)  # 0.62`<br><br>• max(1.4·awx, 1.4·awy, awz) [m/s²], the dominant axis (not the vector total av) |
| `daily_exposure` | `function` | **Daily exposure A(8) for one operation (ISO 5349-1 Eq. 2).**<br>• `total_value`: ahv (hand-arm) or aw,max (whole-body) [m/s²]<br>• `duration_s`: T [s] | `daily_exposure(3.0, 4*3600)  # 2.12`<br><br>• ahv √(T/8h) [m/s²] |
| `partial_exposure` | `function` | **Partial exposure Ai(8) of one operation (Eq. 2).**<br>• `total_value` [m/s²], `duration_s` [s] | `partial_exposure(3.0, 2*3600)  # 1.5` |
| `combine_partial_exposures` | `function` | **Combine partial exposures (Eq. 3).**<br>• `partials`: Ai(8) [m/s²] | `combine_partial_exposures([1.5, 2.1])  # 2.58`<br><br>• √(Σ Ai(8)²) [m/s²] |
| `hav_daily_exposure` | `function` | **A(8) for several operations (ISO 5349-1 Eq. 3).**<br>• `total_values`: ahvi [m/s²]<br>• `durations_s`: Ti [s] | `hav_daily_exposure([3.0, 5.0], [2*3600, 3600])  # 2.32` |
| `energy_equivalent_acceleration` | `function` | **Energy-equivalent magnitude aw,e (ISO 2631-1 Eq. B.3).**<br>• `magnitudes` [m/s²], `durations_s` [s] | `energy_equivalent_acceleration([0.5, 1.0], [3600, 1800])  # 0.707` |
| `hav_vwf_lifetime_years` | `function` | **Years to 10 % vibration-white-finger prevalence (ISO 5349-1 Eq. C.1).**<br>• `a8`: A(8) [m/s²] (> 0) | `hav_vwf_lifetime_years(2.5)  # 12.0`<br><br>• Dy = 31.8 A(8)^−1.06 [years] |
| `exposure_assessment` | `function` | **Assess a daily exposure (Directive 2002/44/EC Article 3).**<br>• `value`: A(8) [m/s²] or VDV [m/s^1.75]<br>• `kind`: 'hav'/'wbv'<br>• `metric`: 'a8' (Default) or 'vdv' (whole-body only) | `ea = exposure_assessment(3.0, kind='hav')`<br><br>• `ExposureAssessment` (zone 'action') |
| `ExposureAssessment` | `dataclass` | **Directive assessment.**<br>• `value`, `kind`, `metric`<br>• `action_value` (EAV), `limit_value` (ELV)<br>• `exceeds_action`, `exceeds_limit`<br>• `zone`: 'below action'/'action'/'limit' | `ea.zone, ea.exceeds_limit` |
| `daily_vibration_exposure` | `function` | **A(8) from several operations, assessed (ISO 5349 + Directive).**<br>• `total_values` [m/s²], `durations_s` [s]<br>• `kind`: 'hav'/'wbv'<br>• `labels` (Default: None → 'op 1', …) | `res = daily_vibration_exposure([3.0, 5.0], [2*3600, 3600], kind='hav')`<br><br>• `DailyVibrationExposure` (A(8) = 2.32) |
| `DailyVibrationExposure` | `dataclass` | **Assessed daily exposure.**<br>• `a8` [m/s²]<br>• `labels`, `total_values`, `durations_s`, `partials`<br>• `assessment`: `ExposureAssessment`<br>• `.plot()` | `res.a8, res.assessment.zone` |
| `HumanVibrationWarning` | `warning class` | **Human-vibration advisory.**<br>Emitted for a crest factor above 9 (ISO 2631-1 6.2.2) or other out-of-range measurement conditions | `warnings.simplefilter('error', HumanVibrationWarning)` |
| `REFERENCE_ACCELERATION` | `float` | **Vibration reference acceleration [m/s²].**<br>1e-6 (ISO 1683 acceleration level reference) | `REFERENCE_ACCELERATION  # 1e-06` |
| `REFERENCE_DURATION_S` | `float` | **Daily-exposure reference duration T0 [s].**<br>28800 (8 h) | `REFERENCE_DURATION_S  # 28800.0` |
| `HAV_EAV_A8` | `float` | **Hand-arm exposure action value [m/s²].**<br>2.5 (Directive 2002/44/EC) | `HAV_EAV_A8  # 2.5` |
| `HAV_ELV_A8` | `float` | **Hand-arm exposure limit value [m/s²].**<br>5.0 | `HAV_ELV_A8  # 5.0` |
| `WBV_EAV_A8` | `float` | **Whole-body A(8) action value [m/s²].**<br>0.5 | `WBV_EAV_A8  # 0.5` |
| `WBV_ELV_A8` | `float` | **Whole-body A(8) limit value [m/s²].**<br>1.15 | `WBV_ELV_A8  # 1.15` |
| `WBV_EAV_VDV` | `float` | **Whole-body VDV action value [m/s^1.75].**<br>9.1 | `WBV_EAV_VDV  # 9.1` |
| `WBV_ELV_VDV` | `float` | **Whole-body VDV limit value [m/s^1.75].**<br>21.0 | `WBV_ELV_VDV  # 21.0` |
| `fdtd_simulation` | `function` | **2D acoustic FDTD wave simulation (staggered grid, leapfrog).**<br>• `c`: sound-speed map [m/s], `(ny, nx)` array or scalar with `shape`<br>• `dx`: grid spacing [m]<br>• `duration`: physical time to simulate [s]<br>• `sources`: `GaussianPulse` \| `CWSource` \| `SignalSource` list<br>• `probes`: `(ix, iy)` pressure-probe cells<br>• `boundaries`: `"rigid"` \| `"absorbing"` \| per-side mapping (name or real impedance [Pa·s/m])<br>• `obstacle_mask`: boolean `(ny, nx)` rigid-cell map / `rho` / `cfl` / `damping` / `absorbing_layer_cells` / `snapshot_every` | `res = fdtd_simulation(343, 0.01, 0.02, shape=(200, 300), sources=[GaussianPulse(ix=60, iy=100, width=3e-4)], probes=[(200, 100)])`<br><br>• `FDTDResult` |
| `FDTDResult` | `dataclass` | **FDTD simulation result.**<br>• `times` [s] / `pressures` [Pa] per probe / `probes` / `probe_positions` [m]<br>• `dx` / `dt` / `shape` / `size` [m] / `sources` / `snapshots` / `snapshot_times` / `obstacle_mask`<br>• `.plot()`: probe histories; `.plot(kind="snapshot")`: pressure field | `res.pressures, res.times`<br>`res.plot()` |
| `FDTD2D` | `class` | **FDTD stepping engine (frame-by-frame access).**<br>• `c` [m/s] / `dx` [m] / `rho` [kg/m³] / `cfl` / `shape`<br>• `sponge_width` / `sponge_sides` / `sponge_reflection` / `damping`<br>• `edge_impedance`: per-side real impedance [Pa·s/m] / `obstacle_mask` | `sim = FDTD2D(343, 0.01, shape=(200, 300))`<br>`sim.add_source(src); sim.step(); sim.run(400, record_every=10)`<br>• `sim.p` / `sim.vx` / `sim.vy` / `sim.dt` / `sim.time` / `sim.energy()` |
| `GaussianPulse` | `dataclass` | **Gaussian pulse source for the FDTD solver.**<br>• `ix` / `iy`: source cell<br>• `width` [s] / `t0` [s] (Default: `4*width`) / `amplitude` [Pa] | `GaussianPulse(ix=60, iy=100, width=3e-4)` |
| `CWSource` | `dataclass` | **Continuous sine source with raised-cosine onset for the FDTD solver.**<br>• `ix` / `iy`: source cell<br>• `frequency` [Hz] / `amplitude` [Pa] / `ramp_cycles` | `CWSource(ix=25, iy=25, frequency=170)` |
| `SignalSource` | `dataclass` | **Arbitrary sampled waveform source for the FDTD solver.**<br>• `ix` / `iy`: source cell<br>• `samples` [Pa] / `sample_rate` [Hz] / `amplitude`<br>Linearly interpolated onto the simulation time steps | `SignalSource(ix=60, iy=100, samples=sig, sample_rate=48000)` |
| `PhonometryWarning` | `warning class` | **Base class for all phonometry warnings.**<br>Catch or silence every library advisory at once | `warnings.simplefilter('error', PhonometryWarning)` |
| `FilterBankWarning` | `warning class` | **Fractional-octave filter-bank advisory.**<br>Emitted for filter-bank processing pitfalls | `warnings.simplefilter('error', FilterBankWarning)` |
| `TonalityWarning` | `warning class` | **Tonality advisory.**<br>Emitted for biased tonality estimates (e.g. coarse FFT resolution) | `warnings.simplefilter('error', TonalityWarning)` |
| `STIWarning` | `warning class` | **STI/STIPA advisory.**<br>Emitted for suspect speech-intelligibility measurements or inputs | `warnings.simplefilter('error', STIWarning)` |
| `octavefilter` / `getansifrequencies` / `normalizedfreq` / `calculate_sensitivity` / `coverage_factor` / `expanded_uncertainty` | `function` | **Deprecated aliases (warn on use; removal in 4.0).**<br>New names: `octave_filter`, `nominal_frequencies`, `normalized_frequencies`, `sensitivity`, `insulation_coverage_factor`, `insulation_expanded_uncertainty` | `octave_filter(x, fs)  # not octavefilter` |
| `__version__` | `str` | **Package version string.**<br>(no parameters) | `phonometry.__version__  # '3.2.0'` |
| `.plot()` | `method` | **One-line canonical figure on every result object (soft matplotlib dependency).**<br>Available on `ZwickerLoudness`, `MooreGlasbergLoudness`, `MooreGlasbergTimeVaryingLoudness`, `EcmaLoudness`, `EcmaTonality`, `EcmaRoughness`, `PsychoacousticAnnoyanceResult`, `FluctuationStrengthResult`, `ProgramLoudnessResult`, `KWeightingResponse`, `STIResult`, `SIIResult`, `StandardSpeechSpectrum`, `NCResult`, `RCResult`, `AgeThresholdResult`, `NiptsResult`, `HtlanResult`, `ImpulseProminenceResult`, `ImpulsiveSoundResult`, `MultipleShockResult`, `ImpulseResponseResult`, `DecayCurve`, `RoomAcousticsResult`, `ReverberationResult`, `ReverberationModelResult`, `DynamicStiffnessResult`, `MobilityResult`, `TransferStiffnessResult`, `VibrationSoundPowerResult`, `StructureBornePowerResult`, `InstalledSourceResult`, `WeightedRatingResult`, `ImpactRatingResult`, `FacadeInsulationResult`, `LabAirborneInsulationResult`, `LabImpactInsulationResult`, `SoundPowerResult`, `ReverberationSoundPowerResult`, `SoundPowerIntensityResult`, `PrecisionSoundPowerResult`, `PrecisionIntensityResult`, `IntensityResult`, `UncertaintyResult`, `AbsorptionRatingResult`, `ScatteringResult`, `DiffusionResult`, `DiffusionSpectrum`, `InsituAbsorptionResult`, `WeightingResponse`, `WeightedSpectrum` and `DailyVibrationExposure`.<br>• `ax`: existing Axes, or None to build a fresh figure (Default: None)<br>• returns the Matplotlib `Axes` (an array of Axes for multi-panel figures); never calls `plt.show()`<br>• needs matplotlib (`pip install phonometry[plot]`) | `res.plot()`<br>`decay_curve(ir, fs).plot()` |

## Notes

- `zero_phase=True` (in `OctaveFilterBank.filter` and `spectrogram`) filters
  forward-backward (`sosfiltfilt`): no group delay, doubled effective
  attenuation, offline analysis only. Incompatible with `stateful=True`. The
  passband also narrows, lowering the measured broadband band level by ~0.2 to
  0.3 dB per band (a pure in-band tone is unaffected); prefer forward filtering
  when the absolute band SPL must match single-pass conventions, and reserve
  zero-phase for when the temporal envelope matters (e.g. reverberation decay).
- `mode='peak'` includes the filter's onset transient; a tone that starts
  abruptly can overshoot by ~1 dB. See [Calibration and dBFS](https://jmrplens.github.io/phonometry/guides/calibration/).
- `octave_filter()` caches filter bank designs internally (32 entries), so
  repeated calls with the same parameters skip the design phase. For explicit
  control use `OctaveFilterBank`.
- Deprecated aliases (kept for one cycle, warn on use, removal in 4.0):
  `octavefilter` → `octave_filter`, `getansifrequencies` →
  `nominal_frequencies`, `normalizedfreq` → `normalized_frequencies`,
  `calculate_sensitivity` → `sensitivity`, `coverage_factor` →
  `insulation_coverage_factor`, `expanded_uncertainty` →
  `insulation_expanded_uncertainty`, plus the renamed names
  `OCTAVE_BANDS_HZ` → `OCTAVE_BANDS`, `THIRD_OCTAVE_BANDS_HZ` →
  `THIRD_OCTAVE_BANDS`, `BASE_PLATE_BANDS_HZ` → `BASE_PLATE_BANDS` and
  `ExposureWarning` → `OccupationalExposureWarning`.

---


<!-- source: docs/theory.md | canonical: https://jmrplens.github.io/phonometry/reference/theory/ -->

# Theoretical Background

The theory reference explains the standards, the mathematics and the design decisions behind every phonometry module. It is split into six domain pages, listed below with the sections each one hosts. Theory for the underwater modules lives with its guides: [Underwater Acoustics](underwater-acoustics.md) and [Underwater Propagation](underwater-propagation.md).

## [Signal Analysis](https://jmrplens.github.io/phonometry/reference/theory/signal-analysis/)

- [Octave Band Frequencies (ANSI S1.11 / IEC 61260)](https://jmrplens.github.io/phonometry/reference/theory/signal-analysis/#octave-band-frequencies-ansi-s111--iec-61260)
- [Frequency Resolution vs FFT Bin Spacing](https://jmrplens.github.io/phonometry/reference/theory/signal-analysis/#frequency-resolution-vs-fft-bin-spacing)
- [Magnitude Responses |H(jw)|](https://jmrplens.github.io/phonometry/reference/theory/signal-analysis/#magnitude-responses-hjw)
- [Filter Bank Design & Numerical Stability](https://jmrplens.github.io/phonometry/reference/theory/signal-analysis/#filter-bank-design--numerical-stability)
- [Weighting Curves (IEC 61672-1)](https://jmrplens.github.io/phonometry/reference/theory/signal-analysis/#weighting-curves-iec-61672-1)
- [Time Integration](https://jmrplens.github.io/phonometry/reference/theory/signal-analysis/#time-integration)
- [G-weighting (ISO 7196)](https://jmrplens.github.io/phonometry/reference/theory/signal-analysis/#g-weighting-iso-7196)
- [Event and dose metrics](https://jmrplens.github.io/phonometry/reference/theory/signal-analysis/#event-and-dose-metrics)
- [Sound intensity (IEC 61043)](https://jmrplens.github.io/phonometry/reference/theory/signal-analysis/#sound-intensity-iec-61043)
- [Measurement uncertainty (ISO/IEC Guide 98-3: GUM and Supplement 1)](https://jmrplens.github.io/phonometry/reference/theory/signal-analysis/#measurement-uncertainty-isoiec-guide-98-3-gum-and-supplement-1)

## [Perception and Hearing](https://jmrplens.github.io/phonometry/reference/theory/perception/)

- [Equal-loudness contours (ISO 226:2023)](https://jmrplens.github.io/phonometry/reference/theory/perception/#equal-loudness-contours-iso-2262023)
- [Tone prominence: TNR and PR (ECMA-418-1)](https://jmrplens.github.io/phonometry/reference/theory/perception/#tone-prominence-tnr-and-pr-ecma-418-1)
- [Zwicker loudness (ISO 532-1)](https://jmrplens.github.io/phonometry/reference/theory/perception/#zwicker-loudness-iso-532-1)
- [Advanced loudness models & sound quality](https://jmrplens.github.io/phonometry/reference/theory/perception/#advanced-loudness-models--sound-quality)
- [Modulation transfer and STI (IEC 60268-16)](https://jmrplens.github.io/phonometry/reference/theory/perception/#modulation-transfer-and-sti-iec-60268-16)
- [Speech Intelligibility Index (ANSI S3.5)](https://jmrplens.github.io/phonometry/reference/theory/perception/#speech-intelligibility-index-ansi-s35)
- [Hearing thresholds and presbycusis (ISO 389-7, ISO 7029)](https://jmrplens.github.io/phonometry/reference/theory/perception/#hearing-thresholds-and-presbycusis-iso-389-7-iso-7029)
- [Noise-induced hearing loss (ISO 1999)](https://jmrplens.github.io/phonometry/reference/theory/perception/#noise-induced-hearing-loss-iso-1999)

## [Rooms and Buildings](https://jmrplens.github.io/phonometry/reference/theory/rooms-buildings/)

- [Room noise criteria (ANSI S12.2)](https://jmrplens.github.io/phonometry/reference/theory/rooms-buildings/#room-noise-criteria-ansi-s122)
- [Room and building acoustics (ISO 18233, ISO 3382, ISO 16283, ISO 10140, EN 12354, ISO 12999, ISO 717, ISO 354)](https://jmrplens.github.io/phonometry/reference/theory/rooms-buildings/#room-and-building-acoustics-iso-18233-iso-3382-iso-16283-iso-10140-en-12354-iso-12999-iso-717-iso-354)

## [Materials and Surfaces](https://jmrplens.github.io/phonometry/reference/theory/materials-surfaces/)

- [Surface scattering and diffusion (ISO 17497-1, ISO 17497-2)](https://jmrplens.github.io/phonometry/reference/theory/materials-surfaces/#surface-scattering-and-diffusion-iso-17497-1-iso-17497-2)
- [In-situ road surface absorption (ISO 13472-1, ISO 13472-2)](https://jmrplens.github.io/phonometry/reference/theory/materials-surfaces/#in-situ-road-surface-absorption-iso-13472-1-iso-13472-2)
- [Acoustic material characterisation (ISO 11654, ISO 9053-1/2, ISO 10534-1/2, ASTM E2611)](https://jmrplens.github.io/phonometry/reference/theory/materials-surfaces/#acoustic-material-characterisation-iso-11654-iso-9053-12-iso-10534-12-astm-e2611)

## [Environment and Transport](https://jmrplens.github.io/phonometry/reference/theory/environment-transport/)

- [Environmental descriptors (ISO 1996-1)](https://jmrplens.github.io/phonometry/reference/theory/environment-transport/#environmental-descriptors-iso-1996-1)
- [Impulsive-sound prominence (NT ACOU 112)](https://jmrplens.github.io/phonometry/reference/theory/environment-transport/#impulsive-sound-prominence-nt-acou-112)
- [Outdoor propagation and occupational exposure (ISO 9613-1/2, ISO 9612)](https://jmrplens.github.io/phonometry/reference/theory/environment-transport/#outdoor-propagation-and-occupational-exposure-iso-9613-12-iso-9612)
- [Sound power determination (ISO 3744/3745/3746, ISO 3741, ISO 9614-2/3)](https://jmrplens.github.io/phonometry/reference/theory/environment-transport/#sound-power-determination-iso-374437453746-iso-3741-iso-9614-23)

## [Vibration](https://jmrplens.github.io/phonometry/reference/theory/vibration/)

- [Human vibration (ISO 8041-1, ISO 2631-1/2, ISO 5349-1/2, Directive 2002/44/EC)](https://jmrplens.github.io/phonometry/reference/theory/vibration/#human-vibration-iso-8041-1-iso-2631-12-iso-5349-12-directive-200244ec)

---


<!-- source: docs/theory-signal-analysis.md | canonical: https://jmrplens.github.io/phonometry/reference/theory/signal-analysis/ -->

# Theory: Signal Analysis

This page collects the theory behind the measurement chain itself: the standardized fractional-octave bands and the time-domain filter banks that implement them, the frequency weighting curves, time integration, level, event and exposure metrics, sound intensity, and the GUM uncertainty framework that underpins every measured quantity. It is part of the [theory reference](https://jmrplens.github.io/phonometry/reference/theory/).

## Octave Band Frequencies (ANSI S1.11 / IEC 61260)

The mid-band frequencies (fm) and edges (f1, f2) use a base-10 ratio:

$$
G = 10^{0.3}
$$

**Mid-band:**

$$
f_m = 1000 \cdot G^{x/b}
$$

(for odd b)

**Band edges:**

$$
f_1 = f_m \cdot G^{-1/(2b)}, \quad f_2 = f_m \cdot G^{1/(2b)}
$$

## Frequency Resolution vs FFT Bin Spacing

`octave_filter` is a **time-domain fractional-octave filter bank**, not an FFT or
Welch spectrum estimator. Therefore, its result does not have a frequency
resolution in the `fs / nfft` sense.

For `fraction=3`, the output contains one scalar level per third-octave band.
The relevant frequency granularity is the standardized band definition: center
frequency, lower edge, and upper edge. Because fractional-octave bands are
logarithmically spaced, their absolute bandwidth in Hz grows with frequency
while their relative bandwidth remains approximately constant.

For example, with `fraction=3` and `limits=[12, 20000]`, the exact third-octave
band around 1 kHz is approximately:

| Nominal band | Lower edge | Center | Upper edge | Bandwidth |
| :--- | ---: | ---: | ---: | ---: |
| 1 kHz | 891.25 Hz | 1000.00 Hz | 1122.02 Hz | 230.77 Hz |

You can inspect the exact bands with:

```python
from phonometry import metrology

fc, fl, fu, labels = metrology.nominal_frequencies(fraction=3, limits=[12, 20000])
for label, center, lower, upper in zip(labels, fc, fl, fu):
    print(label, center, lower, upper, upper - lower)
```

If you need narrowband FFT bins for tonal inspection, run Welch/FFT on the
original signal and use the phonometry band edges as masks:

```python
import numpy as np
from scipy import signal
from phonometry import metrology

fs = 100_000
# any 1D pressure signal in Pa (synthesized here so the example runs)
pressure_signal_pa = 0.02 * np.random.default_rng(0).standard_normal(fs)
x = pressure_signal_pa

# Standardized third-octave levels from phonometry.
levels, centers = metrology.octave_filter(
    x,
    fs=fs,
    fraction=3,
    limits=[12, 20_000],
)

# Same standardized band definitions, including lower/upper edges.
fc, fl, fu, labels = metrology.nominal_frequencies(fraction=3, limits=[12, 20_000])

# Narrowband Welch estimate on the original signal.
nperseg = min(2**15, len(x))
freq_bins, psd = signal.welch(
    x,
    fs=fs,
    window="hann",
    nperseg=nperseg,
    noverlap=nperseg // 2,
    scaling="density",
)

# Example: list the Welch bins inside the third-octave band closest to 1 kHz.
band_index = int(np.argmin(np.abs(np.asarray(fc) - 1000.0)))
in_band = (freq_bins >= fl[band_index]) & (freq_bins <= fu[band_index])

print("Selected third-octave band:", labels[band_index])
print("Welch bin spacing:", freq_bins[1] - freq_bins[0], "Hz")
for f, pxx in zip(freq_bins[in_band], psd[in_band]):
    print(f, pxx)
```

This keeps the two concepts separate: phonometry gives standardized
fractional-octave levels, while Welch gives narrowband FFT bins. With
`fs=100000` and `nperseg=2**15`, the Welch bin spacing is about `3.05 Hz`.
Window choice and overlap affect leakage and averaging variance, but they do not
change the bin spacing of each FFT segment.

When `sigbands=True`, `octave_filter` can also return the time-domain waveform
filtered by each band. Applying Welch/FFT to one selected filtered waveform can
be useful as a diagnostic view of the content inside that filtered band, but it
does not recover FFT bins from the scalar band levels.

## Magnitude Responses |H(jw)|

The library implements standard classical filter prototypes:

**1. Butterworth:** Maximally flat passband.

$$
|H(j\omega)| = \frac{1}{\sqrt{1 + (\omega/\omega_c)^{2n}}}
$$

**2. Chebyshev I:** Equiripple in passband, steeper roll-off.

$$
|H(j\omega)| = \frac{1}{\sqrt{1 + \epsilon^2 T_n^2(\omega/\omega_c)}}
$$

**3. Chebyshev II:** Inverse Chebyshev, equiripple in stopband, flat passband.

$$
|H(j\omega)| = \frac{1}{\sqrt{1 + \frac{1}{\epsilon^2 T_n^2(\omega_{stop}/\omega)}}}
$$

**4. Elliptic:** Equiripple in both, maximum selectivity.

$$
|H(j\omega)| = \frac{1}{\sqrt{1 + \epsilon^2 R_n^2(\omega/\omega_c, L)}}
$$

**5. Bessel:** Maximally flat group delay (linear phase).

$$
H(s) = \frac{\theta_n(0)}{\theta_n(s/\omega_0)}
$$

(Where $\theta_n$ is the reverse Bessel polynomial)

### Band-edge placement

For every architecture the bank places the **−3 dB points on the band edges**.
Two cases need special handling:

- **Chebyshev II**: scipy's `Wn` is the *stopband* edge. phonometry maps the
  desired −3 dB edges to stopband edges analytically (the prototype transition
  ratio is $\cosh(\operatorname{acosh}(\sqrt{10^{A/10}-1})/N)$), applying the
  lowpass→bandpass transform in the pre-warped bilinear domain so the mapping
  stays exact for decimated bands close to Nyquist.
- **Bessel**: designed with `norm="mag"`, which defines the −3 dB point exactly
  at `Wn` (the `phase` norm would shift the edges to roughly −10 dB).

## Filter Bank Design & Numerical Stability

To ensure **100% stability** across the entire audible spectrum (even at low
frequencies like 16 Hz with high sample rates), phonometry employs two
critical strategies:

```mermaid
flowchart LR
    X["Input signal\nfs"] --> D{"Low band?"}
    D -- "yes" --> R["Decimate\nresample_poly (1/M)"] --> S1["SOS band filter\nat fs/M"]
    D -- "no" --> S2["SOS band filter\nat fs"]
    S1 --> L["Band level (RMS/peak)"]
    S2 --> L
    S1 -- "sigbands=True" --> U["Interpolate back\nresample_poly (M/1)"] --> Y["Band signal\nat fs"]
    S2 -- "sigbands=True" --> Y
```

1. **Second-Order Sections (SOS):** All filters are implemented as a series of
   cascaded biquads. This avoids the catastrophic numerical precision loss
   associated with high-order transfer functions (coefficients a, b).
2. **Multi-rate Decimation:** For low-frequency bands, the signal is
   automatically downsampled (decimated) before filtering and upsampled
   afterwards. This keeps the digital pole locations far from the unit circle
   boundary, preventing oscillation and noise. Chebyshev II banks reserve extra
   decimation headroom so their stopband edges stay below the decimated Nyquist.

## Weighting Curves (IEC 61672-1)

The A-weighting transfer function:

$$
R_A(f) = \frac{12194^2 \cdot f^4}{(f^2 + 20.6^2)\sqrt{(f^2 + 107.7^2)(f^2 + 737.9^2)}(f^2 + 12194^2)}
$$

$$
A(f) = 20 \log_{10}(R_A(f)) + 2.00
$$

The digital filter is obtained from the analog poles/zeros via the bilinear
transform. Because the bilinear transform compresses frequencies near Nyquist,
the default `high_accuracy` mode designs and runs the filter at an internally
oversampled rate (≥ 144 kHz); see [Frequency Weighting](https://jmrplens.github.io/phonometry/guides/weighting/).

## Time Integration

Implemented as a first-order IIR exponential integrator:

$$
y[n] = \alpha \cdot x^2[n] + (1 - \alpha) \cdot y[n-1]
$$

$$
\alpha = 1 - e^{-1 / (f_s \cdot \tau)}
$$

Where `tau` is the time constant (e.g., 125 ms for Fast).

The default initial condition is `y[-1] = 0`. Use `initial_state='first'` to
start from the first input energy, or pass a scalar/array with the previous
mean-square output state. See [Why phonometry](https://jmrplens.github.io/phonometry/reference/why-phonometry/) for the
IEC 61672-1 tone-burst verification of this implementation.

## G-weighting (ISO 7196)

The G curve extends frequency weighting into the infrasound range. ISO 7196:1995 Table 1 (p. 2) defines it by four zeros at the origin and four complex-conjugate pole pairs, given as coordinates in Hz (multiplied by $2\pi$ to obtain rad/s):

$$
z_{1..4} = 0, \qquad
p = 2\pi \left\lbrace -0.707 \pm j0.707,\  -19.27 \pm j5.16,\  -14.11 \pm j14.11,\  -5.16 \pm j19.27 \right\rbrace \ \text{Hz}
$$

The gain $k$ is chosen so that the response is exactly **0 dB at 10 Hz** (clause 4):

$$
k = \left| \frac{\prod_i (j\omega_{10} - p_i)}{\prod_i (j\omega_{10} - z_i)} \right|, \qquad \omega_{10} = 2\pi \cdot 10 \ \text{rad/s}
$$

The four zeros against eight poles shape the characteristic response: a rise of approximately **+12 dB/octave between 1 Hz and 20 Hz**, with roll-offs of approximately **24 dB/octave** below 1 Hz and above 20 Hz. Infrasound needs its own curve because near the hearing threshold the perceived loudness of very-low-frequency tones grows much more steeply with sound pressure level than at mid frequencies (a small dB increase above threshold produces a large loudness jump), so the A curve (anchored at 1 kHz) grossly misrepresents infrasonic annoyance.

Since G acts on 0.25 Hz – 315 Hz, far below the Nyquist frequency at audio rates, the frequency warping of the plain bilinear transform (applied without prewarping) is negligible there: about 0.014 % at 315 Hz for $f_s = 48$ kHz, under 0.01 dB on the response. The internal oversampling used for the A/C designs (whose action extends to 16 kHz) is therefore not applied.

See the [Frequency Weighting guide](https://jmrplens.github.io/phonometry/guides/weighting/) for usage.

## Event and dose metrics

**Sound exposure level** (SEL; LAE with A-weighting, IEC 61672-1:2013) normalizes the energy of a discrete event (aircraft flyover, train pass) to a 1 s reference duration:

$$
\mathrm{SEL} = L_{eq,T} + 10 \log_{10}\left(\frac{T}{T_0}\right), \qquad T_0 = 1\ \text{s}
$$

**Sound exposure** $E$ (IEC 61252, 3.1) is the time integral of the squared A-weighted sound pressure, expressed in pascal-squared hours:

$$
E = \int_0^T p_A^2(t)\ dt = \overline{p_A^2} \cdot T \quad [\text{Pa}^2\text{h}]
$$

When the recording is a representative sample of a longer shift, $E$ scales the measured mean square by the actual exposure duration. The **normalized 8 h level** (IEC 61252, 3.3) converts exposure to the steady level that carries the same energy over a nominal working day:

$$
L_{EX,8h} = 10 \log_{10}\left(\frac{E}{8\ \text{h} \cdot p_0^2}\right), \qquad p_0 = 20\ \mu\text{Pa}
$$

It is identical to $L_{EP,d}$ of Directive 86/188/EEC and $L_{EX,8h}$ of ISO 1999 (IEC 61252, 3.3 NOTES 5–6). The anchor of IEC 61252 (3.3 NOTE 4): an exposure of **3.2 Pa²h corresponds to $L_{EX,8h}$ of exactly 90 dB**.

**LCpeak** (IEC 61672-1:2013, subclause 5.13) is the absolute maximum of the C-weighted sound pressure expressed in dB, $L_{Cpeak} = 20\log_{10}(\max|p_C(t)|/p_0)$, the quantity behind the 135/137/140 dB(C) occupational action limits. The implementation is verified against the one-cycle and half-cycle reference responses of Table 5.

See the [Levels guide](https://jmrplens.github.io/phonometry/guides/levels/) for usage and the [Calibration guide](https://jmrplens.github.io/phonometry/guides/calibration/) for absolute-scale setup.

## Sound intensity (IEC 61043)

Sound intensity is the time-averaged acoustic power flux $I = \overline{p u}$. The particle velocity follows from **Euler's equation** (linearized conservation of momentum):

$$
\rho_0 \frac{\partial u}{\partial t} = -\frac{\partial p}{\partial r}
$$

A p-p probe approximates the pressure gradient by the **finite difference** of two microphones a spacer distance $\Delta r$ apart (IEC 61043:1994, definition 3.2):

$$
p = \frac{p_1 + p_2}{2}, \qquad u = -\frac{1}{\rho_0 \Delta r} \int (p_2 - p_1)\ dt, \qquad I = \overline{p\ u}
$$

For stationary signals the same estimator has an exact frequency-domain form through the imaginary part of the one-sided **cross spectrum** $G_{12}$ of the two pressures; the implementation estimates it with Welch-averaged, Hann-windowed segments:

$$
I(f) = -\ \frac{\mathrm{Im}\lbrace G_{12}(f)\rbrace}{2 \pi f\ \rho_0\ \Delta r}
$$

The finite difference underestimates the true plane-wave intensity by the factor

$$
\frac{\sin(k \Delta r)}{k \Delta r}, \qquad k = \frac{2 \pi f}{c}
$$

IEC 61043 clause 7.3 specifies the probe intensity response with exactly this argument and Table 3 tabulates it (e.g. −10.5 dB at 6.3 kHz for a 25 mm spacer). Below $f = 0.1 c / \Delta r$ (i.e. $k \Delta r$ under 0.63) the bias stays within about 0.3 dB; `bias_correction` provides the reciprocal factor per band and `max_valid_frequency` the bound.

The **pressure-intensity index** $\delta_{pI} = L_p - L_I$ measures how reactive the field is: in a free plane progressive wave it equals $10 \log_{10}(\rho_0 c / 400) = 0.14$ dB, while large values flag reactive or noisy fields in which the inter-channel phase error dominates. ISO 9614-1:1993 Annex A generalizes it over a measurement surface as the indicator F2 (with F3 for negative partial power and F4 for field non-uniformity), and the instrument's **dynamic capability** $L_d = \delta_{pI0} - K$ (pressure-residual intensity index minus the bias error factor: 10 dB for grades 1/2, 7 dB for grade 3) must exceed F2 for the measurement to be valid (criterion 1).

See the [Sound Intensity guide](https://jmrplens.github.io/phonometry/guides/intensity/) for usage.

## Measurement uncertainty (ISO/IEC Guide 98-3: GUM and Supplement 1)

Domain budgets like ISO 12999-1 and ISO 9612 Annex C are instances of the
general framework of the GUM (ISO/IEC Guide 98-3:2008). Given a measurement
model $y = f(x_1, \ldots, x_N)$, the law of propagation of uncertainty
(clause 5) combines the input standard uncertainties through sensitivity
coefficients:

$$
u_c^2(y) = \sum_{i=1}^{N} \left( \frac{\partial f}{\partial x_i} \right)^2 u^2(x_i),
$$

generalized to $(c \odot u)^{\top} r\ (c \odot u)$ for correlated inputs. The
sensitivities are obtained by central differences on the user's model callable
(step scaled to $10^{-3}$ of each input uncertainty), so no hand-derived
partials are needed. Type B inputs enter through the clause 4.3 half-width
rules: rectangular $a/\sqrt{3}$ (4.3.7), triangular $a/\sqrt{6}$ (4.3.9),
U-shaped $a/\sqrt{2}$. The expanded uncertainty $U = k\,u_c$ takes $k$ from
the t-distribution at the Welch–Satterthwaite effective degrees of freedom
(Annex G.4):

$$
\nu_{\mathrm{eff}} = \frac{u_c^4}{\sum_i u_i^4 / \nu_i}.
$$

**Supplement 1** (ISO/IEC Guide 98-3-1:2008) propagates the full distributions
instead: $10^6$ Monte Carlo draws (clause 6.4) through the same model give
$u(y)$ and the probabilistically symmetric coverage interval from the
$\frac{1}{2}(1 \mp p)$ fractiles (clause 7.7): the route when the model is
non-linear or the output visibly non-Gaussian. The Guides' own examples are
reproduced: the four-term additive model gives $u_c = 2.0$ and the Monte Carlo
95 % interval $\pm 3.88$ of Supplement 1 clause 9.2/Table 3 (four rectangular
inputs; the output is nearly trapezoidal, not Gaussian, so the interval is
narrower than $\pm 1.96\,u$), and the GUM Annex H.1 end-gauge example gives
$k = t_{0.99}(\nu_{\mathrm{eff}} = 16) = 2.92$ and $U_{99} = 93$ nm.

See the [GUM Uncertainty guide](gum-uncertainty.md) for usage.

## References

- Oppenheim, A. V., & Schafer, R. W. (2010). *Discrete-time signal processing*
  (3rd ed.). Pearson. ISBN 978-0-13-198842-2.
  [Open Library record](https://openlibrary.org/isbn/9780131988422).
  The digital-filter theory behind the SOS cascades, the bilinear transform
  and the multirate decimation of the filter-bank design section.
- Smith, J. O. *Introduction to digital filters with audio applications*
  (online book). Center for Computer Research in Music and Acoustics (CCRMA),
  Stanford University.
  [ccrma.stanford.edu/~jos/filters](https://ccrma.stanford.edu/~jos/filters/).
  Free companion treatment of the classical filter prototypes and their
  magnitude responses.
- Fahy, F. J. (1995). *Sound intensity* (2nd ed.). E&FN Spon.
  ISBN 978-0-419-19810-9.
  [doi:10.4324/9780203475386](https://doi.org/10.4324/9780203475386).
  The physics of the p-p estimator: active intensity, the finite-difference
  bias and the phase-mismatch error budget.
- International Electrotechnical Commission. (2014). *Electroacoustics —
  Octave-band and fractional-octave-band filters — Part 1: Specifications*
  (IEC 61260-1:2014).
  [IEC webstore](https://webstore.iec.ch/en/publication/5063).
  The base-10 mid-band and band-edge definitions of the octave-band section
  and the class masks the banks are verified against.
- International Electrotechnical Commission. (2013). *Electroacoustics —
  Sound level meters — Part 1: Specifications* (IEC 61672-1:2013).
  [IEC webstore](https://webstore.iec.ch/en/publication/5708).
  The A/C/Z weighting curves, the exponential time integration and the SEL
  and LCpeak definitions of the event-metric section.
- International Electrotechnical Commission. (1993). *Electroacoustics —
  Instruments for the measurement of sound intensity — Measurements with
  pairs of pressure sensing microphones* (IEC 61043:1993; adopted in Europe
  as EN 61043:1994).
  [IEC webstore](https://webstore.iec.ch/en/publication/4353).
  The p-p instrument standard: the cross-spectral estimator and the
  pressure-residual intensity index behind the dynamic capability.
- International Organization for Standardization. (1993). *Acoustics —
  Determination of sound power levels of noise sources using sound
  intensity — Part 1: Measurement at discrete points* (ISO 9614-1:1993).
  [iso.org catalogue](https://www.iso.org/standard/17427.html).
  The surface indicators F2–F4 that generalize the pressure-intensity index
  over a measurement surface.
- Joint Committee for Guides in Metrology. (2008). *Evaluation of measurement
  data — Guide to the expression of uncertainty in measurement* (JCGM
  100:2008, the GUM). BIPM.
  [doi:10.59161/JCGM100-2008E](https://doi.org/10.59161/JCGM100-2008E),
  [free PDF](https://www.bipm.org/documents/20126/2071204/JCGM_100_2008_E.pdf).
  The law of propagation of uncertainty and the Welch–Satterthwaite expanded
  uncertainty of the GUM section.
- Joint Committee for Guides in Metrology. (2008). *Evaluation of measurement
  data — Supplement 1 to the "Guide to the expression of uncertainty in
  measurement" — Propagation of distributions using a Monte Carlo method*
  (JCGM 101:2008). BIPM.
  [doi:10.59161/JCGM101-2008](https://doi.org/10.59161/JCGM101-2008),
  [free PDF](https://www.bipm.org/documents/20126/2071204/JCGM_101_2008_E.pdf).
  The Monte Carlo propagation of distributions and its coverage-interval
  construction.

---


<!-- source: docs/theory-perception.md | canonical: https://jmrplens.github.io/phonometry/reference/theory/perception/ -->

# Theory: Perception and Hearing

This page collects the theory behind hearing and psychoacoustics: the equal-loudness contours, the Zwicker, Moore-Glasberg and Sottek loudness models, the sound-quality metrics tonality, roughness and sharpness, tone prominence, the speech metrics STI and SII, and the statistics of hearing thresholds and hearing loss. It is part of the [theory reference](https://jmrplens.github.io/phonometry/reference/theory/).

## Equal-loudness contours (ISO 226:2023)

A tone has a *loudness level* of $L_N$ phon when it is judged equally loud as a 1 kHz pure tone at $L_N$ dB SPL. ISO 226:2023 Formula (1) (clause 4.1, p. 2) gives the SPL of a pure tone at frequency $f$ that reaches loudness level $L_N$:

$$
L_f = \frac{10}{\alpha_f} \log_{10}\left[ \left(4 \cdot 10^{-10}\right)^{0.3 - \alpha_f} \left( 10^{\ 0.03 L_N} - 10^{\ 0.072} \right) + 10^{\ \alpha_f (T_f + L_U)/10} \right] - L_U
$$

Formula (2) (clause 4.2) inverts it, returning the loudness level of a tone at SPL $L_f$:

$$
L_N = \frac{100}{3} \log_{10}\left[ \frac{10^{\ \alpha_f (L_f + L_U)/10} - 10^{\ \alpha_f (T_f + L_U)/10}}{\left(4 \cdot 10^{-10}\right)^{0.3 - \alpha_f}} + 10^{\ 0.072} \right]
$$

The three parameters come from Table 1 (p. 4), tabulated at the 29 preferred third-octave frequencies of ISO 266 from 20 Hz to 12.5 kHz:

- $\alpha_f$: exponent for loudness perception at frequency $f$,
- $L_U$: magnitude of the linear transfer function, normalized at 1 kHz ($L_U = 0$ at 1 kHz),
- $T_f$: threshold of hearing at $f$, in dB.

The standard specifies **no interpolation** between the tabulated frequencies. Formula (1) is specified for **20 phon to 90 phon** between 20 Hz and 4 kHz, and only up to **80 phon between 5 kHz and 12.5 kHz**; above 80 phon the contour therefore stops at 4 kHz. Values outside these limits from Formula (2) are extrapolations the standard labels as informative only.

See the [Loudness guide](https://jmrplens.github.io/phonometry/guides/loudness/) for usage.

## Tone prominence: TNR and PR (ECMA-418-1)

Both methods operate on a Hann-windowed, RMS-averaged power spectrum (clauses 11.1 / 12.1) and use the clause 10 critical-band model. The critical bandwidth centred on a tone at $f$ is (Formula 2):

$$
\Delta f_c = 25.0 + 75.0 \left(1.0 + 1.4 \left(\tfrac{f}{1000}\right)^2\right)^{0.69} \ \text{Hz}
$$

Band edges are placed **arithmetically** for $f \le 500$ Hz (Formulae 4–5): $f_{1,2} = f \mp \Delta f_c / 2$, and **geometrically** above (Formulae 7–8): $f_1 = -\Delta f_c/2 + \sqrt{\Delta f_c^2 + 4 f^2}/2$, $f_2 = f_1 + \Delta f_c$.

**TNR** (clause 11). The tone band spans the spectral minima on both sides of the peak within 15 % of $\Delta f_c$ (clause 11.2). The tone power subtracts the straight line connecting the band-edge bins (Formula 9): over $N$ tone-band bins, $P_t = \sum_k P_k - (P_{\text{lo}} + P_{\text{hi}})\ N/2$. The masking-noise power is the remaining critical-band power rescaled to the full critical bandwidth (Formula 10): $P_n = (P_{\text{band}} - P_t) \cdot \Delta f_c / \Delta f_{\text{band}}$, and $\mathrm{TNR} = 10\log_{10}(P_t/P_n)$ (Formula 11). The prominence criterion (Formulae 12–13) is

$$
\mathrm{TNR}_{\text{crit}} = \begin{cases} 8.0 + 8.33 \log_{10}(1000/f_t) \ \text{dB} & f_t < 1\ \text{kHz} \\ 8.0 \ \text{dB} & f_t \ge 1\ \text{kHz} \end{cases}
$$

**PR** (clause 12) compares the level of the critical band centred on the tone, $L_M$, with the mean power of the two **contiguous** critical bands $L_L$, $L_U$ (edges from the fitted Formulae 21–22 with Tables 2–3): $\mathrm{PR} = 10\log_{10} P_M - 10\log_{10}\left[(P_L + P_U)/2\right]$ (Formula 23). For $f_t \le 171.4$ Hz the lower band is truncated at 20 Hz and its power rescaled to a **100 Hz bandwidth** (Formula 24). The criterion (Formulae 25–26) is 9.0 dB at $f_t \ge 1$ kHz, rising as $9.0 + 10.0\log_{10}(1000/f_t)$ below. Tones are assessed within the 89.1 Hz – 11.2 kHz range of interest (clauses 11.5 / 12.6).

See the [Prominent Discrete Tones guide](https://jmrplens.github.io/phonometry/guides/tone-prominence/) for usage.

## Zwicker loudness (ISO 532-1)

The ear analyzes sound in **critical bands**: frequency regions within which energy is summed before loudness is formed. The **Bark scale** maps frequency to critical-band rate $z$, 0 to 24 Bark, and ISO 532-1:2017 samples the specific loudness $N'(z)$ at 0.1-Bark steps (240 values). The implementation is a clean-room port of the standard's normative reference program (Annex A.4) and proceeds in stages:

1. **One-third-octave levels**: 28 bands, 25 Hz to 12.5 kHz (the Annex A filterbank at 48 kHz, Tables A.1/A.2). For time-varying sounds the squared band outputs are smoothed by three cascaded low-passes with $\tau = 2/(3 f_c)$ ($f_c$ capped at 1 kHz) and sampled every 2 ms.
2. **Low-frequency grouping**: the 11 bands up to 250 Hz receive the equal-loudness corrections of Table A.3 and are summed into the first three critical bands (25–80, 100–160, 200–250 Hz).
3. **a0 transmission**: the outer/middle-ear transfer correction of Table A.4 (plus the diffuse-field difference of Table A.5 when `field='diffuse'`) yields the critical-band levels $L_E$.
4. **Core loudness**: each of the 20 critical bands is transformed with the threshold-in-quiet levels $L_{TQ}$ of Table A.6 (after the bandwidth adaptation DCB of Table A.7):

   $$
   N_c = \max\left(0,\ 0.0635 \cdot 10^{0.025 L_{TQ}} \left[ \left( 1 - s + s \cdot 10^{(L_E - L_{TQ})/10} \right)^{0.25} - 1 \right]\right) \ \text{sone/Bark}, \qquad s = 0.25
   $$

   (the reference program's form of Zwicker's loudness transformation; bands below threshold contribute zero).

5. **Slopes**: level-dependent upper masking slopes (steepness per specific-loudness range and critical band, Tables A.8/A.9) attach decaying flanks toward higher $z$; the total loudness is the area under the pattern:

   $$
   N = \int_0^{24} N'(z)\ dz \ \ \text{sone}
   $$

For time-varying sounds a nonlinear temporal decay (time constants 5/15/75 ms, clause 6.3) and the duration-dependent weighting of the total loudness (3.5 ms and 70 ms low-passes weighted 0.47/0.53, clause 6.4) precede the 500 Hz loudness-vs-time output and the percentile values N5/N10 (clause 6.5).

**Sone and phon** are tied together by the 1 kHz anchor (1 sone = 40 phon; clause 5.6):

$$
N = 2^{(L_N - 40)/10} \ \text{sone} \qquad \Longleftrightarrow \qquad L_N = 40 + 10 \log_2 N \ \text{phon} \qquad (N \ge 1)
$$

below 1 sone the reference program uses $L_N = 40 (N + 0.0005)^{0.35}$, floored at 3 phon.

See the [Loudness guide](https://jmrplens.github.io/phonometry/guides/loudness/) for usage.

## Advanced loudness models & sound quality

ISO 532-1 is one of three loudness models; two newer families refine the auditory front-end and add the sound-quality metrics tonality and roughness.

### Moore-Glasberg loudness (ISO 532-2:2017, ISO 532-3:2023)

Instead of Zwicker's fixed critical bands, the Moore-Glasberg model forms a continuous **excitation pattern** on the ERB-number ("Cam") scale using level-dependent **rounded-exponential (roex)** auditory filters. As a function of the normalized frequency deviation $g = |f - f_c| / f_c$ from a filter centred at $f_c$, the filter weighting is

$$
W(g) = (1 + p\ g)\ e^{-p\ g}
$$

where the slope $p$ grows with the source level, broadening the lower skirt as level rises (ISO 532-2, Formulae 2–5); this reproduces the upward spread of masking. Passing the stimulus intensity through every filter gives the excitation $E(i)$, and a compressive law maps it to the **specific loudness** $N'(i)$ in sone/Cam (Formulae 7–9), of the mid-level form

$$
N'(i) = C \left[ \left( G\ \frac{E(i)}{E_0} + A \right)^{\alpha} - A^{\alpha} \right]
$$

with the calibration constant $C = 0.0617$ sone/Cam (ISO 532-2; $0.063$ in ISO 532-3). The total loudness is the area under the pattern,

$$
N = \int N'(i)\ di \ \ \text{sone}
$$

and a binaural-inhibition stage (Formulae 10–13) combines the ears so a diotic sound is louder than the same sound at one ear. The 1 kHz / 40 dB SPL anchor gives exactly 1 sone.

**ISO 532-3** makes this time-varying. A running spectrum from six parallel Hann-windowed FFTs (segment lengths 2–64 ms, each contributing its own frequency range, updated every $T_0 = 1$ ms) drives the same excitation and specific-loudness chain, integrated by two cascaded first-order smoothers with $\alpha = 1 - e^{-T_0 / \tau}$,

$$
S(t) = \alpha\ x(t) + (1 - \alpha)\ S(t - 1)
$$

using a fast time constant on the attack and a slower one on the release. This yields the **short-term loudness** $S'(t)$ (attack/release near 20–30 ms) and the **long-term loudness** $S''(t)$ (near 0.1–0.75 s); the peak long-term loudness $N_{\max} = \max_t S''(t)$ predicts the loudness of sounds up to about 5 s.

### Sottek Hearing Model (ECMA-418-2:2025)

ECMA-418-2 builds all three of its metrics on one auditory front-end (Clause 5): an outer/middle-ear filter, a bank of 53 overlapping gammatone-like band-pass filters spaced on the Bark_HMS scale ($z = 0.5$ to $26.5$), half-wave rectification, and a short-block RMS $\tilde{p}(l, z)$ per band $z$ and time block $l$. A compressive nonlinearity (Formula 23) turns the band RMS into the **specific basis loudness** $N'_{\mathrm{basis}}(l, z)$, whose calibration constant $c_N$ fixes a 1 kHz / 40 dB SPL tone at 1 sone_HMS. The loudness assembles the tonal and noise loudness (below) over bands and time (Formulae 113–117); it grows about $1.65\times$ per 10 dB, more slowly than Zwicker's factor of 2, an intrinsic property of the Sottek summation.

### Tonality: autocorrelation of the band signal (ECMA-418-2)

A tonal component is periodic, so it survives in the **autocorrelation function** (ACF) of a band's rectified signal while broadband noise decorrelates. For each band the unbiased ACF of the block is

$$
\phi_z(m) = \frac{1}{M - m} \sum_{n=0}^{M - 1 - m} p_z(n)\ p_z(n + m)
$$

A windowed spectral estimate of $\phi_z$ separates a **tonal loudness** $N'_{\mathrm{tonal}}(l, z)$ from the **noise loudness** $N'_{\mathrm{noise}}(l, z)$ (Formulae 36–48). The specific tonality is the tonal loudness scaled by a smooth signal-to-noise gate $q(l)$ (Formulae 49–51),

$$
T'(l, z) = c_T\ q(l)\ N'_{\mathrm{tonal}}(l, z)
$$

and the single value $T$ (tu_HMS) is the gated time-average of the per-block maximum over bands (Formulae 61–64). The constant $c_T$ fixes the 1 kHz / 40 dB tone at 1 tu_HMS, and the band of the ACF peak gives the tonal frequency $f_{\mathrm{ton}}$.

### Roughness: envelope modulation (ECMA-418-2)

Roughness is the sensation of fast (roughly 20–300 Hz) amplitude modulation, strongest near 70 Hz. From each band's envelope $p_E(n)$ (Hilbert magnitude), a modulation spectrum is formed and weighted by a modulation-rate function peaking near 70 Hz and by the modulation depth; correlating the modulation across neighbouring bands and applying the specified temporal filtering yields the **specific roughness** $R'(l_{50}, z)$ and the time-dependent roughness

$$
R(l_{50}) = \sum_z R'(l_{50}, z) \ \ \text{asper}
$$

(Formulae 65–111). The single value $R$ is the 90th percentile of $R(l_{50})$ over time (Clause 7.1.10); the constant $c_R$ (Formula 104) calibrates the reference sound (a 1 kHz carrier 100 % amplitude-modulated at 70 Hz at 60 dB SPL) to 1 asper.

### Sharpness (DIN 45692)

Sharpness condenses the high-frequency emphasis of a sound into one number: the $g(z)$-weighted first moment of the ISO 532-1 stationary specific-loudness pattern (DIN 45692:2009, Equation 1):

$$
S = k\ \frac{\int_0^{24} N'(z)\ g(z)\ z\ dz}{\int_0^{24} N'(z)\ dz} \ \text{acum}, \qquad
g(z) = \begin{cases} 1 & z \le 15.8\ \text{Bark} \\ 0.15\ e^{0.42 (z - 15.8)} + 0.85 & z > 15.8\ \text{Bark} \end{cases}
$$

evaluated on the same 240-bin, 0.1-Bark grid. The constant $k$ is not hard-coded but derived from the calibration requirement (clause 6): a critical-band-wide narrowband noise 920–1080 Hz at 60 dB SPL scores exactly 1 acum, and the derived $k = 0.108$ lands inside the normative window $0.105 \le k < 0.115$ (clause 5.2). The informative Annex B weightings are provided under the same 1-acum anchor: von Bismarck (knee at 15 Bark, $0.2\ e^{0.308(z-15)} + 0.8$) and Aures (loudness-dependent, $g(z) = 0.078\ (e^{0.171 z}/z)\ N/\ln(0.05 N + 1)$). The Table A.2 narrow-band targets are reproduced within the clause 6 tolerance (5 % or 0.05 acum): 0.38 acum at 250 Hz, 1.00 at 1 kHz, 1.78 at 2.5 kHz, 2.82 at 4 kHz.

See the [Sound Quality Metrics guide](https://jmrplens.github.io/phonometry/guides/sound-quality/) for usage.

## Modulation transfer and STI (IEC 60268-16)

Speech intelligibility rides on the slow intensity modulations of the speech envelope. The **modulation transfer function** $m(F)$ of a transmission channel is the ratio of received to emitted modulation depth of the octave-band intensity envelope at modulation frequency $F$; the full STI evaluates it at the 14 one-third-octave modulation frequencies 0.63–12.5 Hz in the seven octave bands 125 Hz – 8 kHz (A.2.2). From a measured impulse response the **Schroeder closed form** gives it directly (indirect method):

$$
m_k(f_m) = \frac{\left| \int_0^{\infty} h_k^2(t)\ e^{-j 2 \pi f_m t}\ dt \right|}{\int_0^{\infty} h_k^2(t)\ dt}
$$

Steady background noise multiplies each band's $m$ by the intensity ratio (the noise term):

$$
m'_k = m_k \cdot \frac{I_k}{I_k + I_{n,k}} = \frac{m_k}{1 + 10^{-\mathrm{SNR}_k/10}}
$$

and when absolute band levels are known the full correction $m'_k = m_k I_k / (I_k + I_{am,k} + I_{rt,k} + I_{n,k})$ adds the auditory masking intensity $I_{am,k}$ (from the next lower octave band, Table A.2) and the absolute reception threshold $I_{rt,k}$ (Table A.3). Each corrected $m$ maps to an **effective SNR**, clipped to the ±15 dB range where intelligibility actually varies, then to a transmission index (A.5.4/A.5.5):

$$
\mathrm{SNR}_{\mathrm{eff}} = 10 \log_{10} \frac{m}{1 - m}\ \text{dB}, \qquad \mathrm{TI} = \frac{\mathrm{SNR}_{\mathrm{eff}} + 15}{30}
$$

The band MTI is the mean TI over the modulation frequencies, and the STI weights the bands with the male factors $\alpha_k$, $\beta_k$ of Ed. 5 Table A.1 (A.5.6):

$$
\mathrm{STI} = \sum_{k=1}^{7} \alpha_k\ \mathrm{MTI}_k - \sum_{k=1}^{6} \beta_k \sqrt{\mathrm{MTI}_k\ \mathrm{MTI}_{k+1}}
$$

truncated to 1.0. STIPA (Annex B) samples the same physics with just two modulation frequencies per band (Table B.1) on a test signal with source modulation index 0.55; the received depths are measured by sine/cosine correlation of the ~100 Hz low-passed intensity envelopes $I_k(t)$ over an integer number of modulation periods:

$$
m_{dr} = \frac{2 \sqrt{\left( \sum_t I_k(t) \sin 2 \pi f_m t \right)^2 + \left( \sum_t I_k(t) \cos 2 \pi f_m t \right)^2}}{\sum_t I_k(t)}, \qquad m = \frac{m_{dr}}{0.55}
$$

See the [Speech Transmission Index guide](https://jmrplens.github.io/phonometry/guides/speech-transmission/) for usage.

## Speech Intelligibility Index (ANSI S3.5)

Where the STI characterizes a transmission channel, the SII (ANSI S3.5-1997) predicts intelligibility from what the listener can actually hear: 18 one-third-octave bands 160 Hz – 8 kHz, each contributing its band importance $I_i$ (Table 3, $\sum I_i = 1$, peaking near 2 kHz). All inputs are equivalent spectrum levels (clauses 3.11/3.55). Speech masks itself upward: each band's masking spectrum $Z_i$ (clause 5.4) accumulates the lower bands along slopes $C_i = -80 + 0.6\,(B_i + 10 \lg f_i - 6.353)$ dB, and the disturbance is the **larger** of masking and hearing floor, $D_i = \max(Z_i, X'_i)$ (clause 5.6), with $X'_i = X_i + T'_i$ the reference internal noise spectrum plus the listener's hearing-threshold shift (clauses 5.5/5.6). The band audibility clips the speech-to-disturbance margin into $[0, 1]$ (clause 5.8), a level-distortion factor discounts overly loud presentation (clause 5.7), and the index sums (clause 6):

$$
A_i = \operatorname{clip}\Big( \frac{E'_i - D_i + 15}{30},\ 0,\ 1 \Big), \qquad
L_i = \operatorname{clip}\Big( 1 - \frac{E'_i - U_i - 10}{160},\ 0,\ 1 \Big), \qquad
\mathrm{SII} = \sum_{i=1}^{18} I_i\ L_i\ A_i .
$$

The Table 3 standard speech spectra for the normal, raised, loud and shout vocal efforts are built in (25.01 / 33.86 / 42.16 / 51.31 dB at 1 kHz); $U_i$ in the level-distortion factor is always the normal-effort spectrum. The anchor values: the normal-effort spectrum in quiet with normal hearing scores SII ≈ 0.996, the masking-spectrum reference values are matched to $10^{-4}$, and the vocal-effort spectra are cross-verified against the Google and CRAN reference implementations.

See the [Speech Intelligibility guide](https://jmrplens.github.io/phonometry/guides/speech-intelligibility/) for usage.

## Hearing thresholds and presbycusis (ISO 389-7, ISO 7029)

ISO 389-7:2005 Table 1 fixes the reference threshold of hearing of otologically normal young adults: the free-field and diffuse-field SPL corresponding to 0 dB HL at the 11 audiometric frequencies 125 Hz – 8 kHz (22.1 dB at 125 Hz for both fields, 2.4/0.8 dB free/diffuse at 1 kHz, diverging at high frequency to 12.6 vs 6.8 dB at 8 kHz). ISO 7029:2017 describes how that threshold shifts statistically with age: the median deviation from age 18 is (clause 4.2, Table 1)

$$
\Delta H_{md} = a\ (Y - 18)^b \ \text{dB},
$$

and any fractile follows a two-sided Gaussian model (clause 4.4), $\Delta H_Q = \Delta H_{md} + z(Q)\ s$, using the upper spread $s_u$ for $z \ge 0$ (worse than median) and the lower spread $s_l$ otherwise, each a degree-5 polynomial in $Y - 18$ per sex and frequency (clause 4.3, Tables 2–5). At age 18 every deviation is zero by construction. The formulae are established to 80 years at and below 2 kHz and to 70 years above; beyond that the evaluation is an extrapolation. Anchors: at 60 years the medians evaluate to 7.85 dB (male, 1 kHz), 20.21 dB (male, 4 kHz) and 15.32 dB (female, 4 kHz), matching the Table 1 formula to $10^{-3}$.

See the [Hearing Threshold guide](hearing-threshold.md) for usage.

## Noise-induced hearing loss (ISO 1999)

ISO 1999:2013 predicts the permanent threshold shift a noise-exposed population accrues. The median noise-induced shift (NIPTS) for 10–40 years of exposure is (clause 6.3.1, Formula 2, Table 1):

$$
N_{50} = \big[ u + v \lg(t/t_0) \big]\ (L_{EX,8h} - L_0)^2, \qquad t_0 = 1\ \text{yr},
$$

quadratic in the excess over the frequency-dependent onset level $L_0$ (75 dB at 4 kHz, the most sensitive band, up to 93 dB at 500 Hz) and zero below it; under 10 years it scales as $\lg(t+1)/\lg 11$ (Formula 3). Fractiles add the spread, $N_Q = N_{50} + z\ d_{u,l}$ with $d = (X + Y \lg t)(L_{EX,8h} - L_0)^2$ (clause 6.3.2, Formulae 4–7, Tables 2/3), clamped at zero; the convention counts the fraction of the population with the *smaller* shift, so $Q = 0.9$ is the most-susceptible decile (reliable range 0.05–0.95). The hearing threshold level associated with age and noise (HTLAN) combines NIPTS with the ISO 7029 age component at the same fractile through the compressed sum (clause 6.1, Formula 1):

$$
H' = H + N - \frac{H\ N}{120}.
$$

The Annex D worked examples (Tables D.1–D.4; e.g. 100 dB / 40 yr at 3 kHz: 29/38/60 dB at the 0.10/0.50/0.90 fractiles) are reproduced exactly at the standard's integer rounding, and the Formula 2 hand value at 4 kHz / 20 yr / 90 dB is $N_{50} = 12.94$ dB.

See the [Noise-Induced Hearing Loss guide](noise-induced-hearing-loss.md) for usage.

## References

- Fletcher, H., & Munson, W. A. (1933). Loudness, its definition, measurement
  and calculation. *The Journal of the Acoustical Society of America*, 5(2),
  82-108. [doi:10.1121/1.1915637](https://doi.org/10.1121/1.1915637).
  The original equal-loudness measurements behind the contour concept of the
  first section.
- Fastl, H., & Zwicker, E. (2007). *Psychoacoustics: Facts and models*
  (3rd ed.). Springer.
  [doi:10.1007/978-3-540-68888-4](https://doi.org/10.1007/978-3-540-68888-4).
  The critical-band, masking and loudness psychoacoustics underneath the
  Zwicker model and the sharpness and roughness sensations.
- Houtgast, T., & Steeneken, H. J. M. (1985). A review of the MTF concept in
  room acoustics and its use for estimating speech intelligibility in
  auditoria. *The Journal of the Acoustical Society of America*, 77(3),
  1069-1077. [doi:10.1121/1.392224](https://doi.org/10.1121/1.392224).
  The modulation-transfer framework of the STI section.
- French, N. R., & Steinberg, J. C. (1947). Factors governing the
  intelligibility of speech sounds. *The Journal of the Acoustical Society of
  America*, 19(1), 90-119.
  [doi:10.1121/1.1916407](https://doi.org/10.1121/1.1916407).
  The articulation-band experiments behind the SII band-importance function.
- Passchier-Vermeer, W. (1974). Hearing loss due to continuous exposure to
  steady-state broad-band noise. *The Journal of the Acoustical Society of
  America*, 56(5), 1585–1593.
  [doi:10.1121/1.1903482](https://doi.org/10.1121/1.1903482).
  A field study of the exposure-response relations later codified in
  ISO 1999.
- International Organization for Standardization. (2023). *Acoustics —
  Normal equal-loudness-level contours* (ISO 226:2023).
  [iso.org catalogue](https://www.iso.org/standard/83117.html).
  The Formula (1)/(2) contour model and the Table 1 parameters of the
  equal-loudness section.
- Ecma International. (2024). *ECMA-418-1: Psychoacoustic metrics for ITT
  equipment — Part 1: Prominent discrete tones* (3rd ed.).
  [Free PDF](https://ecma-international.org/wp-content/uploads/ECMA-418-1_3rd_edition_december_2024.pdf).
  The critical-band model and the TNR and PR procedures of the
  tone-prominence section.
- International Organization for Standardization. (2005). *Acoustics —
  Reference zero for the calibration of audiometric equipment — Part 7:
  Reference threshold of hearing under free-field and diffuse-field listening
  conditions* (ISO 389-7:2005).
  [iso.org catalogue](https://www.iso.org/standard/38976.html).
  The Table 1 free-field and diffuse-field reference thresholds of the
  hearing-threshold section.
- International Organization for Standardization. (2017). *Acoustics —
  Statistical distribution of hearing thresholds related to age and gender*
  (ISO 7029:2017). [iso.org catalogue](https://www.iso.org/standard/42916.html).
  The age-dependent median shift and fractile spreads of the presbycusis
  model.
- International Organization for Standardization. (2013). *Acoustics —
  Estimation of noise-induced hearing loss* (ISO 1999:2013).
  [iso.org catalogue](https://www.iso.org/standard/45103.html).
  The NIPTS model, its fractiles and the HTLAN compressed sum of the
  hearing-loss section.

---


<!-- source: docs/theory-rooms-buildings.md | canonical: https://jmrplens.github.io/phonometry/reference/theory/rooms-buildings/ -->

# Theory: Rooms and Buildings

This page collects the theory behind rooms and buildings: impulse-response measurement and the room-acoustic parameters, background-noise criteria, airborne and impact insulation with their single-number ratings and uncertainty, and flanking and absorption prediction. It is part of the [theory reference](https://jmrplens.github.io/phonometry/reference/theory/); surface scattering and acoustic material characterisation live on [Materials and Surfaces](https://jmrplens.github.io/phonometry/reference/theory/materials-surfaces/).

## Room noise criteria (ANSI S12.2)

ANSI/ASA S12.2-2019 rates steady background noise in rooms against families of octave-band curves (16 Hz – 8 kHz). The **NC rating** follows the two-step procedure of clause 5.2.2 on the Table 1 curves (NC-15 to NC-70): the speech interference level $\mathrm{SIL} = \tfrac14(L_{500}+L_{1000}+L_{2000}+L_{4000})$ (clause 3.2) selects the NC-(SIL) curve, and if no band exceeds it the spectrum is designated NC-(SIL); otherwise the tangency method (clause 5.2.3) applies: each measured band is interpolated against the tabulated curve values, the rating is the highest per-band index and the band that sets it is the governing band; the interpolation makes the rating continuous (an NC-42.5 is reported as such, not snapped to a curve). Spectra above NC-70 or below NC-15 fall outside the family and are flagged (>NC-70 with the band of maximum exceedance, or <NC-15) instead of receiving a fabricated number. The **RC Mark II** contour (Annex D) is a pure −5 dB/octave line keyed to its 1000 Hz value with a low-frequency floor of $\max(\mathrm{RC} + 25,\ 55)$ dB at 16/31.5 Hz; the rating is the arithmetic mean of the 500/1000/2000 Hz levels rounded to an integer (clause D.4), and the spectral-quality tag compares the spectrum with the reference contour (clause D.3): rumble "R" when any band at or below 500 Hz exceeds it by more than 5 dB, hiss "H" when any band at or above 1 kHz exceeds it by more than 3 dB (both together "RH"), else neutral "N", reported as e.g. RC-35(N). The generated RC contours reproduce Table D.1 digit for digit, and feeding any Table 1 NC curve back returns its own tangency rating. NCB, RNC (Annex A) and the QAI (clause D.5) are deliberately out of scope.

See the [Room Noise guide](room-noise.md) for usage.

## Room and building acoustics (ISO 18233, ISO 3382, ISO 16283, ISO 10140, EN 12354, ISO 12999, ISO 717, ISO 354)

### Deterministic-excitation impulse response (ISO 18233)

A room/transmission path is modelled as **linear time-invariant**, so its impulse response $h(t)$ carries everything. ISO 18233 replaces the classical noise-burst decay with a deterministic excitation that is **deconvolved** into $h(t)$, gaining 20–30 dB of effective signal-to-noise ratio. The exponential sine sweep (ESS, Annex B) has instantaneous frequency $f(t) = f_1 (f_2/f_1)^{t/T}$, so its phase is the closed-form integral of $2 \pi f(t)$:

$$
\varphi(t) = \frac{2 \pi f_1 T}{\ln(f_2/f_1)} \left[ \left( \frac{f_2}{f_1} \right)^{t/T} - 1 \right] .
$$

A constant time-per-octave makes the ESS spectrum pink (−3 dB/octave). Deconvolution is done by **linear** (non-circular, zero-padded) spectral division $H = Y\ \overline{X} / (|X|^2 + \varepsilon)$, the Tikhonov term $\varepsilon$ (a fraction of $\max |X|^2$) preventing noise blow-up at the band edges. Since a low-to-high sweep places harmonic-distortion products at negative arrival times, they fall in the wrapped tail and are removed by keeping the causal part (Farina). The MLS method (Annex A) instead exploits that the circular autocorrelation of a maximum-length sequence of length $2^N-1$ is a periodic delta, so $h = \operatorname{xcorr}_{\text{circ}}(\text{recorded}, \text{mls}) / 2^N$; synchronous averaging of $n$ periods adds $10 \log_{10} n$ dB.

### Schroeder backward integration (ISO 3382-1, 5.3.3)

The band decay curve is the **backward-integrated** squared IR (Schroeder):

$$
E(t) = \int_t^{\infty} p^2(\tau)\ d\tau = \int_0^{\infty} p^2\ d\tau - \int_0^t p^2\ d\tau , \qquad L(t) = 10 \log_{10} \frac{E(t)}{E(0)}\ \text{dB},
$$

i.e. a reversed cumulative sum in discrete time. Backward integration cancels the random fluctuation of a single squared IR: for a purely exponential energy decay $p^2(t) = e^{-a t}$ it gives $E(t) = e^{-a t}/a$, an exactly straight line $L(t) = -(10 a / \ln 10)\ t$. Background noise flattens $E(t)$, so integration is truncated at the crossing $t_1$ of the fitted decay line with the noise level and the missing tail is compensated by an exponential with the fitted rate; without that term the finite integral systematically **underestimates** $T$.

### Regression windows and validity (ISO 3382-2, Clause 6, Annex B/C)

Reverberation time is a least-squares fit $L = a + b t$ over a window, extrapolated to 60 dB via $T = -60/b$ (Annex C): **EDT** on 0 to −10 dB, **T20** on −5 to −25 dB, **T30** on −5 to −35 dB. A single-slope decay gives EDT = T20 = T30; a fast early / slow late double slope gives EDT < T30. Validity uses the dynamic-range rule of 5.3.3: the noise must sit at least 25 dB below the IR peak for EDT (evaluation span + 15 dB), tightened to 46 dB for T20 and 54 dB for T30 so the tail-compensation bias of a flagged-valid value stays within the 5 % JND. The **curvature** $C = 100\ (T_{30}/T_{20} - 1)$ % (Annex B) flags a non-straight decay above 10 %.

### Clarity, definition and centre time (ISO 3382-1, Annex A)

Splitting the energy at an early/late boundary $t_e$ gives the early-to-late index and the definition ratio:

$$
C_{te} = 10 \log_{10} \frac{\int_0^{t_e} p^2\ dt}{\int_{t_e}^{\infty} p^2\ dt}\ \text{dB}, \qquad D_{50} = \frac{\int_0^{0.05} p^2\ dt}{\int_0^{\infty} p^2\ dt}, \qquad C_{50} = 10 \log_{10} \frac{D_{50}}{1 - D_{50}},
$$

with $t_e = 50$ ms (C50, speech) or 80 ms (C80, music), and the **centre time** $T_s = \int_0^{\infty} t\ p^2\ dt / \int_0^{\infty} p^2\ dt$. For a pure exponential decay these have closed forms $C_{te} = 10 \log_{10}(e^{a t_e} - 1)$ and $T_s = 1/a$; at $T = 1$ s ($a = 13.8155$) they evaluate to C80 = 3.05 dB, C50 = −0.02 dB, D50 = 0.499 and Ts = 72.4 ms, the values the implementation reproduces. Table A.1 JNDs (EDT 5 %, C80 1 dB, D50 0.05, Ts 10 ms) bound how finely each is worth reporting.

### Open-plan spatial decay (ISO 3382-3, Clause 6)

The spatial decay rate of A-weighted speech is the ordinary least-squares slope of $L_{p,A,S}$ against $\lg(r/r_0)$ ($r_0 = 1$ m) over the 2–16 m positions, rescaled to a per-doubling figure, and the nominal level is read off the same line at 4 m:

$$
L = a + b\ \lg(r/r_0), \qquad D_{2,S} = -\lg(2)\ b, \qquad L_{p,A,S,4\text{m}} = a + b\ \lg(4/r_0).
$$

The distraction distance rD and privacy distance rP are the distances where a **linear** (not logarithmic) regression of STI against distance crosses 0.50 and 0.20; a non-negative fitted slope (STI not falling with distance) makes them undefined, realising the standard's "can prove impossible to determine" note.

### Image-source room impulse response (Kuttruff 4.1, Vorländer 11)

A rectangular room reflects a point source in its walls; each reflection equals the free-field sound of a **mirror image** of the source. Mirroring a coordinate in a wall ($S_n = S - 2 d\,\mathbf{n}$, Vorländer Eq. 11.36) turns the source into a regular lattice of images, and the room impulse response is the sum of the direct sound and one delayed, attenuated impulse per image (Kuttruff Eqs. 4.4–4.5),

$$
g(t) = \sum_n A_n\ \delta(t - t_n), \qquad
A_n = \frac{1}{4\pi r_n}\ e^{-m r_n / 2} \prod_{\text{walls}} R_w^{\,k_{w,n}}, \qquad
t_n = \frac{r_n}{c},
$$

with the $1/(4\pi r_n)$ spherical spreading, the product of the wall **pressure reflection factors** $R_w = \sqrt{1 - \alpha_w}$ (Vorländer Eq. 11.39; $|R|^2 = 1-\alpha$ in energy) each raised to the number of reflections $k_{w,n}$ that image made off wall $w$, and the air pressure attenuation $e^{-m r_n/2}$ ($m$ the *intensity* attenuation constant). Along one axis the reflection counts of the image at lattice index $n$ and parity $p$ are $|n-p|$ and $|n|$ off the two walls (Allen & Berkley 1979), so the total order is $\sum_i |2 n_i - p_i|$; a shoebox has $\tfrac{2}{3}(2 i_0^3 + 3 i_0^2 + 4 i_0)$ audible images up to order $i_0$ (Kuttruff Eq. 9.23), and the reflection density grows as $\mathrm{d}N/\mathrm{d}t = 4\pi c^3 t^2 / V$ (Kuttruff Eq. 4.6).

The initial decay rate of the specular reverberant energy recovers the **Eyring** reverberation time $T = -24 V \ln 10 / (c S \ln(1 - \bar\alpha))$ (Kuttruff Eq. 5.23), because the mean reflection rate $cS/4V$ equals $\tfrac{c}{2}(1/L_x + 1/L_y + 1/L_z)$. The match is exact only near cubic geometry; an elongated room sustains energy along its long axis, so the pure specular decay runs slower than Eyring's diffuse-field estimate (the anisotropy the Fitzroy/Arau-Puchades models correct). The model is specular only — no diffraction or diffuse scattering — and exact only for real, angle-independent reflection factors.

### Steady-state room field (Bies 6.4, Kuttruff 5.6)

A source of constant power sets up a steady level made of a direct field falling with distance and a diffuse **reverberant** field that is (approximately) uniform. With the **room constant** $R = S\bar\alpha/(1-\bar\alpha)$ (Bies Eq. 6.44) and the directivity factor $Q$,

$$
L_p = L_W + 10 \lg\!\left( \frac{Q}{4\pi r^2} + \frac{4}{R} \right) \left[ + 10 \lg\frac{\rho c}{400} \right] \quad \text{(Bies Eq. 6.43)},
$$

the optional last term (about $+0.14$ dB at 20 °C) correcting a characteristic impedance away from 400 Pa·s/m. The **critical distance** $r_c = \sqrt{Q R / 16\pi}$ is where the two fields cross; Kuttruff's reverberation distance (Eq. 5.44, $r_c = \sqrt{A/16\pi}$ for $Q=1$) uses the Sabine area $A = S\bar\alpha$ instead of $R = A/(1-\bar\alpha)$, the two coinciding for small $\bar\alpha$. The **Schroeder frequency** $f_s = 2000\sqrt{T/V}$ (Kuttruff Eq. 3.44) roughly marks the modal-to-diffuse transition, a heuristic crossover rather than a sharp cutoff: well below it discrete room modes dominate and these diffuse-field relations grow unreliable, well above it the modes overlap and the relations hold. Borderline rooms still warrant a band-by-band check. See the [Image sources and steady-state field guide](https://jmrplens.github.io/phonometry/guides/room-image-sources/).

### Field insulation and weighted rating (ISO 16283-1, ISO 717-1)

Per one-third-octave band the level difference $D = L_1 - L_2$ (energy-averaged over microphone positions, $L = 10 \log_{10}[(1/n) \sum_i 10^{L_i/10}]$) is normalised two ways: the standardized level difference $D_{nT} = D + 10 \log_{10}(T/T_0)$ with $T_0 = 0.5$ s (so $D_{nT} = D$ when $T = T_0$), and the apparent sound reduction index $R' = D + 10 \log_{10}(S/A)$ with the Sabine absorption area $A = 0.16\ V / T$, hence $R' = D + 10 \log_{10}[S T / (0.16\ V)]$.

The single-number rating (ISO 717-1, Clause 4.4) shifts the Table 3 **reference curve** in 1 dB steps toward the measured curve until the sum of *unfavourable* deviations $\sum_i \max(0, \text{ref}_i + k - \text{meas}_i)$ is maximal but $\le$ 32.0 dB (16 thirds) or 10.0 dB (5 octaves); the rating $R_w$ is the shifted reference at 500 Hz. The **spectrum adaptation terms** are $C = X_{A1} - X_w$ and $C_{tr} = X_{A2} - X_w$ with $X_{Aj} = -10 \log_{10} \sum_i 10^{(L_{ij} - X_i)/10}$ (Table 4 spectra No. 1 pink noise, No. 2 urban traffic), each rounded to an integer. The ISO 717-1 Annex C worked example ($R_w = 30$, $C = -2$, $C_{tr} = -3$, unfavourable sum 31.8 dB) is reproduced exactly.

### Impact insulation and absorption (ISO 16283-2, ISO 717-2, ISO 354)

Impact insulation swaps the airborne source for a standardized **tapping
machine** and rates the receiving-room level, so the sign conventions flip. The
standardized and normalized impact levels are $L'_{nT} = L_i - 10 \log_{10}(T/T_0)$
(the reverberation term is *subtracted*, opposite to $D_{nT}$) and
$L'_n = L_i + 10 \log_{10}(A/A_0)$ with $A_0 = 10$ m² and $A = 0.16\ V/T$. The
ISO 717-2 rating shifts the Table 3 reference curve until $\sum_i \max(0, \text{meas}_i - (\text{ref}_i + k))$
is maximal but $\le$ 32.0 dB (16 thirds) or 10.0 dB (5 octaves); the
*unfavourable* deviation now counts where the **measurement exceeds** the
reference (impact noise is worse when louder), the mirror image of ISO 717-1.
The rating is the shifted reference at 500 Hz, reduced by a further 5 dB for
octave bands, and the adaptation term is $C_I = L_{n,\text{sum}} - 15 - L_{n,w}$
with the energetic sum $L_{n,\text{sum}} = 10 \log_{10} \sum_i 10^{L_i/10}$ over
100–2500 Hz (thirds) or 125–2000 Hz (octaves). The ISO 717-2 Annex C examples
are reproduced exactly (thirds $L_{n,w} = 79$, $C_I = -11$; octaves $54$, $0$),
via the same monotone shift search as ISO 717-1 run on the negated curves.

Sound absorption (ISO 354) measures the equivalent absorption area from
Sabine's relation applied to a reverberation room empty and with the specimen:
$A = 55.3\ V/(c\ T) - 4 V m$ (the $4 V m$ term is the air absorption, $m$ the
power attenuation coefficient in 1/m), so the specimen area is
$A_T = A_2 - A_1$ and its coefficient $\alpha_s = A_T/S$. With the speed of
sound from Eq. (6), $c = 331 + 0.6\ t$ (°C), and $m$ converted from an
ISO 9613-1 attenuation coefficient by $m = \alpha / (10 \lg e)$. Because
diffraction and edge scattering intercept more than the flat sample area,
$\alpha_s$ is left unclamped and may exceed 1.0 (Clause 3.7 NOTE 2).

### Laboratory vs field normalization (ISO 10140, ISO 16283)

The field indices carry a prime because they include flanking transmission
around the partition; the laboratory indices do not, because a qualified
facility suppresses it. The algebra is otherwise identical, differing only in
which quantity is normalised. The airborne pair is the direct laboratory sound
reduction index $R = L_1 - L_2 + 10 \log_{10}(S/A)$ (ISO 10140-2) versus the
apparent field index $R' = L_1 - L_2 + 10 \log_{10}(S/A)$ (ISO 16283-1), the
same closed form evaluated with the facility's known $A$ or the room's measured
$A = 0.16\ V/T$. The impact pair is the normalized laboratory level
$L_n = L_i + 10 \log_{10}(A/A_0)$ (ISO 10140-3) versus the field $L'_n$
(ISO 16283-2), both referenced to $A_0 = 10$ m². Before either is formed the
receiving-room level is corrected for background noise by the energy
subtraction $L = 10 \log_{10}(10^{L_{sb}/10} - 10^{L_b/10})$ for a 6–15 dB
signal-to-background margin, capped at a fixed $1.3$ dB (the limit of
measurement) at or below 6 dB and omitted at or above 15 dB (ISO 10140-4,
Clause 4.3), the laboratory analogue of the 6/10 dB rule of ISO 16283-1. The
façade extension (ISO 16283-3) replaces the source-room level by the level 2 m
in front of the façade, $D_{2m} = L_{1,2m} - L_2$, and adds a fixed
angle-of-incidence correction to the element sound reduction index, $-1.5$ dB
for the 45° loudspeaker method ($R'_{45°}$) and $-3$ dB for the all-angle
road-traffic method ($R'_{tr,s}$); all three carry the ISO 717-1 airborne
single number.

### Flanking transmission prediction (EN 12354-1/2)

The apparent field index is the energetic sum of the direct path $Dd$ and, for
each flanking element $F=f$ across its junction with the separating element, the
three paths $Ff$, $Df$ and $Fd$ (EN 12354-1, simplified single-number model,
Formula 26):

$$
R'_w = -10 \log_{10}\Big[ 10^{-R_{Dd,w}/10}
       + \sum 10^{-R_{Ff,w}/10} + \sum 10^{-R_{Df,w}/10}
       + \sum 10^{-R_{Fd,w}/10} \Big].
$$

The direct path is $R_{Dd,w} = R_{s,w} + \Delta R_{Dd,w}$ (Formula 27), the
separating-element laboratory index plus any lining improvement. Each flanking
path (Formula 28a) is

$$
R_{ij,w} = \frac{R_{i,w} + R_{j,w}}{2} + \Delta R_{ij,w} + K_{ij}
         + 10 \log_{10}\frac{S_s}{l_0\ l_f},
$$

with $R_{i,w}$, $R_{j,w}$ the laboratory indices of the two elements meeting at
the junction ($i$ source side, $j$ receiving side), $\Delta R_{ij,w}$ the
combined lining improvement, $S_s$ the separating-element area, $l_f$ the
junction coupling length and $l_0 = 1$ m the reference coupling length. $K_{ij}$
is the junction **vibration reduction index** (Annex E), an empirical function of
the mass ratio $M = \log_{10}(m'_{\perp,i}/m'_i)$: for a rigid cross-junction
$K_{13} = 8.7 + 17.1 M + 5.7 M^2$ (through) and $K_{12} = 8.7 + 5.7 M^2$
(corner), read at 500 Hz, and floored at $K_{ij,\min} = 10 \log_{10}[l_f\ l_0
(1/S_i + 1/S_j)]$ (Formula 29). Two linings combine as $\max(a,b) + \min(a,b)/2$
(Formulas 30/31). The impact counterpart (EN 12354-2, Formula 21) is the direct
subtraction $L'_{n,w} = L_{n,w,eq} - \Delta L_w + K$, with the bare-floor
equivalent level $L_{n,w,eq} = 164 - 35 \log_{10}(m'/m'_0)$ (Annex B), the
covering improvement $\Delta L_w$ (ISO 717-2) and the flanking correction $K$
from Table 1. The EN 12354-1 Annex H.3 ($R'_w = 52$ dB) and EN 12354-2 Annex E.3
($L'_{n,w} = 45$ dB) worked examples are reproduced exactly; the simplified
model is stated to have about a 2 dB standard deviation (Clause 5).

### Absorption in enclosed spaces (EN 12354-6)

EN 12354-6:2003 predicts the equivalent absorption area of a room from its
parts (the normative Clause 4 model). The total (Formula 1) sums the surfaces,
the objects and the air:

$$
A = \sum_i \alpha_{s,i}\ S_i + \sum_j A_{obj,j} + \sum_k \alpha_{s,k}\ S_k + A_{air},
\qquad A_{air} = 4\ m\ V\ (1 - \psi),
$$

with $m$ the power attenuation coefficient of air (Formula 2; Table 1
tabulates it for six temperature/humidity climates over the octave bands
125 Hz – 8 kHz), $\psi = \sum V_{obj} / V$ the volume fraction occupied by
objects (Formula 3), and a hard irregular object approximated by
$A_{obj} = V_{obj}^{2/3}$ (Formula 4). The reverberation time follows from
Sabine applied to the free volume (clause 4.4, Formula 5):

$$
T = \frac{55.3}{c_0}\ \frac{V\ (1 - \psi)}{A},
$$

with $c_0 = 345.6$ m/s chosen so that $55.3/c_0$ is the familiar $0.16$
(clause 4.4 NOTE). The three Annex E worked cases are reproduced: the
bare 29.75 m³ room gives $A = 2.26$ m² and $T = 2.1$ s at 1 kHz, and adding
hard objects ($\psi \approx 0.072$) raises $A$ to 5.03 m² and drops $T$ to
0.9 s. The informative Annex D method for irregular spaces and unevenly
distributed absorption is out of scope.

See the [Enclosed-Space Absorption guide](enclosed-space-absorption.md) for usage.

### Measurement uncertainty (ISO 12999-1)

ISO 12999-1 supplies the uncertainty of the quantities above from
inter-laboratory (ISO 5725) reproducibility and repeatability rather than a
GUM functional model. Three **measurement situations** fix the standard
uncertainty $u$: situation **A** (laboratory characterisation) uses the
reproducibility standard deviation $\sigma_R$; situation **B** (same location,
different teams) the in-situ $\sigma_{situ}$; situation **C** (same location,
operator and equipment, repeated) the repeatability $\sigma_r$. The per-band and
single-number values are tabulated for airborne $R$/$R'$/$D_n$/$D_{nT}$
(Tables 2/3), impact $L_n$/$L'_n$ (Table 4 bands, situations B/C only; Table 5
ratings adding a situation-A estimate) and the
covering reduction $\Delta L$ (Tables 6/7, situation A only). The expanded
uncertainty is $U = k\ u$ (Formula 2) with the coverage factor $k$ of Table 8
(at 95 %, $k = 1.96$ two-sided, $k = 1.65$ one-sided; a minimum $k = 1$ is
enforced). A two-sided interval $Y = y \pm U$ reports a value (Formula 3); a
one-sided factor declares conformity, $y - U > $ requirement for a lower limit
(Formula 5) or $y + U <$ requirement for an upper limit (Formula 4).
Uncorrelated components combine in quadrature $u_c = \sqrt{\sum u_i^2}$
(Formula C.2), $m$ independent measurements reduce $u$ to $u/\sqrt{m}$
(Formula A.7), and the uncorrelated single-number uncertainty is the
energy-weighted quadrature sum of the band uncertainties (Formula B.2).

See the [Room Acoustics](https://jmrplens.github.io/phonometry/guides/room-acoustics/) and
[Field Insulation Measurement and Ratings](https://jmrplens.github.io/phonometry/guides/insulation-field/) guides for usage.

### Predicted panel sound insulation (Bies 7.2, Hopkins 2.9/4.3.10, Cremer 5)

Where EN 12354 takes the element $R$ as a measurement, the sound reduction index
of a panel can also be **predicted** from its physical properties. A limp panel
follows the mass law $TL_0 = 10\lg[1 + (\pi f m''/\rho_0 c_0)^2]$ (Bies Eq. 7.40),
which rises 6 dB per octave and 6 dB per doubling of the surface mass $m''$; the
field-incidence value subtracts 5.5 dB (one-third octave). Stiffness adds a
**coincidence dip** at $f_c = (c_0^2/2\pi)\sqrt{m''/B'}$ (Eq. 7.3), where the free
bending wavelength matches the acoustic trace wavelength. Sharp's method holds
the mass law to $f_c/2$, drops linearly in $\log f$ to the dip
$TL = 20\lg(f_c m'') + 10\lg\eta - 44$ and rises again above $f_c$ with the loss
factor $\eta$ (Eq. 7.44). A **double wall** is a mass-spring-mass system with the
cavity as the spring: below $f_0 = 60\sqrt{(m_1+m_2)/(m_1 m_2 d)}$ (Eq. 7.62) it
follows the mass law of the combined mass, and above it the two leaves' mass laws
add plus the cavity term $20\lg(2kd)$, saturating at +6 dB beyond
$f_l = c_0/(2\pi d)$ (Eq. 7.64); a porous fill lowers $f_0$. Small air paths cap
any construction: the transmission coefficient of a straight slit (Gomperts,
Hopkins Eq. 4.99, with resonances at $d + 2e = z\lambda/2$) or a circular hole
(Wilson & Soroka, Eq. 4.102) combines with the wall in the area-weighted energy
sum $R = -10\lg[(1/\sum S_n)\sum S_n 10^{-R_n/10}]$ (Eq. 4.92), so a bare opening
of relative area $S_a/S$ limits the composite to $10\lg(S/S_a)$. The resonant
transmission path and the double-wall radiation draw on the plate radiation
efficiency and point mobilities of the
[vibration theory](https://jmrplens.github.io/phonometry/reference/theory/vibration/). The prediction is clean-room from Bies,
Hansen & Howard (2017), Hopkins (2007) and Cremer, Heckl & Petersson (2005).

See the [Predicting Panel Sound Insulation](https://jmrplens.github.io/phonometry/guides/panel-sound-insulation/) guide for
usage.

## References

- Kuttruff, H. (2016). *Room acoustics* (6th ed.). CRC Press.
  [doi:10.1201/9781315372150](https://doi.org/10.1201/9781315372150).
  The statistical decay theory behind backward integration and the Sabine
  relations used throughout this page, the image-source construction
  (Section 4.1), the Eyring reverberation and reverberation distance
  (Sections 5.5–5.6) and the Schroeder frequency (Section 3.6).
- Vorländer, M. (2020). *Auralization: Fundamentals of acoustics, modelling,
  simulation, algorithms and acoustic virtual reality* (2nd ed.). Springer.
  [doi:10.1007/978-3-030-51202-6](https://doi.org/10.1007/978-3-030-51202-6).
  The image-source / mirror-source model of the impulse-response section
  (Chapter 11).
- Allen, J. B., & Berkley, D. A. (1979). Image method for efficiently
  simulating small-room acoustics. *The Journal of the Acoustical Society of
  America*, 65(4), 943-950.
  [doi:10.1121/1.382599](https://doi.org/10.1121/1.382599).
  The reflection-count decomposition of the rectangular-room image lattice.
- Bies, D. A., Hansen, C. H., & Howard, C. Q. (2017). *Engineering noise
  control* (5th ed.). CRC Press.
  [doi:10.1201/9781351228152](https://doi.org/10.1201/9781351228152).
  The steady-state room field and the room constant (Section 6.4).
- Schroeder, M. R. (1965). New method of measuring reverberation time.
  *The Journal of the Acoustical Society of America*, 37(3), 409-412.
  [doi:10.1121/1.1909343](https://doi.org/10.1121/1.1909343).
  The backward-integration method of the decay-curve section.
- Hak, C. C. J. M., Wenmaekers, R. H. C., & van Luxemburg, L. C. J. (2012).
  Measuring room impulse responses: Impact of the decay range on derived
  room acoustic parameters. *Acta Acustica united with Acustica*, 98(6),
  907-915. [doi:10.3813/aaa.918574](https://doi.org/10.3813/aaa.918574).
  The INR decay-range analysis behind the tightened T20/T30 validity
  thresholds.
- Beranek, L. L. (1957). Revised criteria for noise in buildings. *Noise
  Control*, 3(1), 19-27.
  [doi:10.1121/1.2369239](https://doi.org/10.1121/1.2369239).
  The original NC curves rated by the tangency method of the room-noise
  section.
- Blazier, W. E. (1997). RC Mark II: A refined procedure for rating the
  noise of heating, ventilating, and air-conditioning (HVAC) systems in
  buildings. *Noise Control Engineering Journal*, 45(6), 243-250.
  [doi:10.3397/1.2828446](https://doi.org/10.3397/1.2828446).
  The RC Mark II contour and spectral-quality tag codified by ANSI/ASA
  S12.2 Annex D.
- Hopkins, C. (2007). *Sound insulation*. Butterworth-Heinemann.
  ISBN 978-0-7506-6526-1.
  [doi:10.4324/9780080550473](https://doi.org/10.4324/9780080550473).
  The measurement chains, flanking transmission and EN 12354 prediction
  framework of the insulation sections.
- Vigran, T. E. (2008). *Building acoustics*. CRC Press.
  ISBN 978-0-415-42853-8.
  [doi:10.1201/9781482266016](https://doi.org/10.1201/9781482266016).
  Sound transmission in buildings, from single and double constructions to
  floating floors.
- Acoustical Society of America. (2019). *Criteria for evaluating room
  noise* (ANSI/ASA S12.2-2019).
  [ANSI webstore](https://webstore.ansi.org/standards/asa/ansiasas122019).
  The normative NC tangency method and the Annex D RC Mark II rating with
  its spectral tag.
- International Organization for Standardization. (2006). *Acoustics —
  Application of new measurement methods in building and room acoustics*
  (ISO 18233:2006).
  [iso.org catalogue](https://www.iso.org/standard/40408.html).
  The swept-sine and MLS deconvolution of the deterministic-excitation
  section.
- International Organization for Standardization. (2009). *Acoustics —
  Measurement of room acoustic parameters — Part 1: Performance spaces*
  (ISO 3382-1:2009).
  [iso.org catalogue](https://www.iso.org/standard/40979.html).
  Backward integration, the parameter definitions and the Annex A clarity
  family.
- International Organization for Standardization. (2008). *Acoustics —
  Measurement of room acoustic parameters — Part 2: Reverberation time in
  ordinary rooms* (ISO 3382-2:2008).
  [iso.org catalogue](https://www.iso.org/standard/36201.html).
  The regression windows, dynamic-range rules and curvature check of the
  validity section.
- International Organization for Standardization. (2012). *Acoustics —
  Measurement of room acoustic parameters — Part 3: Open plan offices*
  (ISO 3382-3:2012).
  [iso.org catalogue](https://www.iso.org/standard/46520.html).
  The open-plan spatial decay and the distraction and privacy distances.
- International Organization for Standardization. (2014). *Acoustics — Field
  measurement of sound insulation in buildings and of building elements —
  Part 1: Airborne sound insulation* (ISO 16283-1:2014).
  [iso.org catalogue](https://www.iso.org/standard/55997.html).
  The field level differences and normalizations of the insulation
  sections.
- International Organization for Standardization. (2020). *Acoustics —
  Rating of sound insulation in buildings and of building elements — Part 1:
  Airborne sound insulation* (ISO 717-1:2020).
  [iso.org catalogue](https://www.iso.org/standard/77435.html).
  The reference-curve shift and the spectrum adaptation terms C and Ctr.
- International Organization for Standardization. (2003). *Acoustics —
  Measurement of sound absorption in a reverberation room* (ISO 354:2003).
  [iso.org catalogue](https://www.iso.org/standard/34545.html).
  The reverberation-room absorption measurement and its air-absorption
  term.
- European Committee for Standardization. (2003). *Building acoustics —
  Estimation of acoustic performance of buildings from the performance of
  elements — Part 6: Sound absorption in enclosed spaces*
  (EN 12354-6:2003).
  [BSI Knowledge record (BS EN 12354-6:2003)](https://knowledge.bsigroup.com/products/building-acoustics-estimation-of-acoustic-performance-of-buildings-from-the-performance-of-elements-sound-absorption-in-enclosed-spaces).
  The Clause 4 absorption model and Annex E worked cases of the
  enclosed-space section.
- International Organization for Standardization. (2020). *Acoustics —
  Determination and application of measurement uncertainties in building
  acoustics — Part 1: Sound insulation* (ISO 12999-1:2020).
  [iso.org catalogue](https://www.iso.org/standard/73930.html).
  The measurement situations, tabulated uncertainties and coverage factors
  of the uncertainty section.

---


<!-- source: docs/theory-materials-surfaces.md | canonical: https://jmrplens.github.io/phonometry/reference/theory/materials-surfaces/ -->

# Theory: Materials and Surfaces

This page collects the theory behind materials and surfaces: surface scattering and diffusion, in-situ road-surface absorption, and acoustic material characterisation from the weighted absorption rating to airflow resistance and the impedance tube. It is part of the [theory reference](https://jmrplens.github.io/phonometry/reference/theory/). The ISO 354 reverberation-room measurement that feeds the ISO 11654 rating is covered in [Theory: Rooms and Buildings](https://jmrplens.github.io/phonometry/reference/theory/rooms-buildings/).

## Surface scattering and diffusion (ISO 17497-1, ISO 17497-2)

### Random-incidence scattering coefficient (ISO 17497-1)

A rough surface splits the reflected energy into a specular and a scattered
part; the scattering coefficient $s$ is the non-specular energy fraction.
ISO 17497-1:2004+A1:2014 measures it in a reverberation room with the test
sample on a turntable: four reverberation times, stationary and rotating,
each without and with the sample (Table 2), give the random-incidence
absorption $\alpha_s$ (clause 8.1.1, Formula 1) and the *specular* absorption
$\alpha_{spec}$ (clause 8.1.2, Formula 4). Rotation decorrelates the scattered
reflections between decays, so they average out and register as extra
"absorption", and the scattering coefficient follows (clause 8.1.3,
Formula 5):

$$
s = \frac{\alpha_{spec} - \alpha_s}{1 - \alpha_s},
$$

each $\alpha$ being a two-condition Sabine difference
$55.3 (V/S) [1/(c_b T_b) - 1/(c_a T_a)] - 4 (V/S)(m_b - m_a)$ with
$c = 343.2 \sqrt{(273.15 + t)/293.15}$ (Formula 2) and $m$ from ISO 9613-1
via $m = \alpha_{dB}/(10 \lg e)$ (Formula 3). The base plate itself must
scatter little: Table 1 caps its coefficient (Formula 6) at 0.05–0.25 across
100 Hz – 5 kHz (clause 6.2). Negative $s$ is truncated to zero for
presentation (clause 8.3), but values above 1 near grazing bands are kept
(clause 6.3.2). The Annex A uncertainty chain ($u_\alpha$, Formulae A.3/A.4;
$u_s$, Formula A.5; $U = 2 u_s$) is implemented. Since the standard prints no
worked example, the oracle is a synthetic end-to-end chain
($V = 200$ m³, $S = 10$ m², $T = 8.0/6.0/7.5/5.0$ s → $s = 0.093$) plus the
Formula A.5 hand value $u_s = 0.0297$.

### Directional diffusion coefficient (ISO 17497-2)

ISO 17497-2:2012 measures, in the free field, how uniformly a surface spreads
its reflected polar response over $n$ microphones. The autocorrelation-based
coefficient (clause 8.1, Formula 5) is

$$
d_\theta = \frac{\left( \sum_i p_i \right)^2 - \sum_i p_i^2}{(n - 1) \sum_i p_i^2},
\qquad p_i = 10^{L_i/10},
$$

1 for a perfectly uniform response and tending to 0 for a single specular
lobe; Formula 6 is the area-weighted form with $N_i = A_i / A_{min}$ from the
Formula 8 solid-angle factors ($A_i = (4\pi/\Delta\phi) \sin^2(\Delta\theta/4)$
at the zenith). Normalizing against a flat reference reflector of the same
size removes edge diffraction (clause 8.2, Formula 7):
$d_{\theta,n} = (d_\theta - d_{\theta,r})/(1 - d_{\theta,r})$. The
random-incidence value averages the source angles with weights 1:3:3:3:3 for
0°, ±30°, ±60° (clause 8.4). Anchors: the model-predicted 37-receiver arc of
the published six-period N = 7 QRD (Cox & D'Antonio 3rd ed., Appendix B;
Hargreaves et al. 2000, Table I) at 1000 Hz gives $d_\theta = 0.1099$, its
flat reference $0.0049$ and $d_{\theta,n} = 0.1055$; the band-averaged model
predictions match the published Appendix B BEM normalised diffusion in the
200-400 Hz bands within 0.01 (a low-band anchor: the broadband 100-5000 Hz
mean absolute deviation is about 0.09); zenith area factor 1.5710.

See the [Surface Scattering guide](surface-scattering.md) for usage.

## In-situ road surface absorption (ISO 13472-1, ISO 13472-2)

ISO 13472-1:2002 (extended surface method) recovers the normal-incidence
absorption of a road surface in place, from one microphone above it: the
direct and reflected components of an impulse response are separated by the
**subtraction technique** and the **Adrienne window** (clause 6.4: a sharp
leading edge, a mandated 5 ms flat top and a Blackman-Harris trailing edge),
and

$$
\alpha(f) = 1 - \frac{1}{K_r^2} \left| \frac{H_r(f)}{H_i(f)} \right|^2,
\qquad K_r = \frac{d_s - d_m}{d_s + d_m} = \frac{2}{3}
$$

for the mandatory geometry $d_s = 1.25$ m, $d_m = 0.25$ m (clause 4.2,
Annex C); $K_r$ is the spherical-spreading ratio between the direct and the
image path. Ratioing the road measurement against one on a highly reflective
reference surface cancels the entire electro-acoustic chain along with $K_r$
(Annex B). The 5 ms window bounds the sampled area (Annex A closed form:
radius ≈ 1.34 m for the standard geometry) and the valid range is
250 Hz – 4 kHz in one-third octaves. ISO 13472-2:2010 (spot method,
250–1600 Hz) instead couples a small impedance tube to the surface and defers
the mathematics to the ISO 10534-2 transfer-function method below (its
clauses 4/5.7/6.6); the implementation reuses that module, adding the Part 2
geometry and validity limits ($f_u = 0.58\ c_0/d$; microphone spacing bounds
$0.45\ c_0/f_{max}$ and $0.05\ c_0/f_{min}$, clause 5.4) and the Annex A
subtractive correction for internal system losses.

See the [Surface Scattering guide](surface-scattering.md) for usage.

## Acoustic material characterisation (ISO 11654, ISO 9053-1/2, ISO 10534-1/2, ASTM E2611)

### Weighted sound absorption (ISO 11654)

ISO 11654:1997 condenses an ISO 354 third-octave absorption curve into a
single number. The practical coefficient $\alpha_p$ averages the three thirds
of each octave 250 Hz – 4 kHz and rounds to steps of 0.05 (clause 4.1). The
reference curve (0.80, 1.00, 1.00, 1.00, 0.90 at 250–4000 Hz) is then shifted
downward in 0.05 steps until the sum of unfavourable deviations, counted only
where the measurement falls *below* the shifted curve, is $\le 0.10$;
$\alpha_w$ is the shifted curve at 500 Hz (clause 4.2). A shape indicator
flags excess absorption $\ge 0.25$ above the shifted curve: L at 250 Hz, M at
500/1000 Hz, H at 2000/4000 Hz (clause 4.3), and the informative Annex B maps
$\alpha_w$ to the absorption classes A–E. Because every quantity is a multiple
of 0.05, the implementation does the whole grid arithmetic in integer
twentieths, making the shift search and class boundaries exact and
float-safe. The two Annex A worked examples are reproduced:
$\alpha_p = (0.35, 0.70, 0.65, 0.60, 0.55)$ → $\alpha_w = 0.60$, class C; and
raising 500 Hz to 1.00 keeps $\alpha_w = 0.60$ but adds the indicator, "0.60(M)".

### Airflow resistance (ISO 9053-1/2)

Airflow resistivity $\sigma = R\,A/d$ is the key transport parameter of a
porous absorber. ISO 9053-1:2018 (static method) drives a steady flow through
the specimen and fits $\Delta p = a\,u + b\,u^2$ through the origin
(clause 7.5); since $R_s = \Delta p / u = a + b\,u$, the linear coefficient is
the zero-velocity specific resistance, reported at the reference velocity
$u = 0.5$ mm/s. ISO 9053-2:2020 (alternating method) replaces the flowmeter
with a ~2 Hz piston and a microphone in a closed cavity (clause 8.7,
Formula 2):

$$
R = \kappa'\ \frac{p_s}{2 \pi f V}\ \frac{h_t}{h_s}\ 10^{(L_{ps} - L_{pt})/20}
$$

Only a level *difference* enters, so the sound-level device needs no
absolute calibration. The effective exponent $\kappa'$ (Annex A,
Formula A.7) corrects the adiabatic $\kappa$ for wall heat conduction through
the thermal boundary layer $b = \sqrt{2 c_0 l_h / \omega}$ (Formulae A.4/A.5).
The Annex A.3 worked example (100 mm closed cylinder at 2 Hz: $b = 1.83$ mm,
$\kappa' = 1.370 = 0.978\,\kappa$) is reproduced, and the validity guards of
Formula 3 (transfer ratio < 0.3) and Formula 4 (10 dB background margin) are
enforced.

### Impedance tube (ISO 10534-1, ISO 10534-2, ASTM E2611)

A tube below its cut-on frequency ($f d < 0.58\ c_0$ circular,
$< 0.50\ c_0$ rectangular; microphone-spacing limits $f s < 0.45\ c_0$ and
$f > c_0/(20 s)$; clauses 4.2–4.5) carries only plane waves, so the surface
reflection factor of a sample is fully observable. ISO 10534-2
(transfer-function method) compares the measured two-microphone transfer
function $H_{12}$ with the analytic incident and reflected ones
$H_I = e^{-j k_0 s}$, $H_R = e^{+j k_0 s}$ (Annex D) to give (clause 7,
Eq. 17):

$$
r = \frac{H_{12} - H_I}{H_R - H_{12}}\ e^{2 j k_0 x_1}, \qquad
\alpha = 1 - |r|^2, \qquad \frac{Z}{\rho c_0} = \frac{1 + r}{1 - r},
$$

with the complex wavenumber's attenuation lower bound
$k_0'' = 1.94 \times 10^{-2} \sqrt{f}/(c_0 d)$ (Eq. A.18). ISO 10534-1
(standing-wave-ratio method) is the closed-form classic:
$|r| = (s - 1)/(s + 1)$ from the max/min ratio $s = 10^{\Delta L/20}$ and the
phase from the first-minimum position (Eqs. 12–26); an SWR of 3 gives exactly
$|r| = 0.5$ and $\alpha = 0.75$. ASTM E2611-19 adds transmission: four
microphones decompose the up- and downstream fields into the $A, B, C, D$
waves (Eqs. 17–20) and a two-load (or symmetric one-load) solve yields the
specimen's 2×2 **transfer matrix** $[p; u]_0 = T\,[p; u]_d$ (Eqs. 16/22–24),
from which the anechoic-backing normal-incidence transmission loss is
(Eqs. 25/26)

$$
TL = 20 \lg \frac{\left| T_{11} + T_{12}/\rho c + \rho c\ T_{21} + T_{22} \right|}{2},
$$

plus the hard-backed reflection
$R = (T_{11} - \rho c\,T_{21})/(T_{11} + \rho c\,T_{21})$ (Eq. 27), the
material wavenumber $\arccos(T_{11})/d$ (Eq. 29) and the characteristic
impedance $\sqrt{T_{12}/T_{21}}$ (Eq. 30). The three standards deliberately
keep their own sign ansatz and temperature units (ISO in kelvin, ASTM in
Celsius), and near-singular load solves raise a warning. Since neither
standard prints a numeric example, the oracles are physics identities: the
analytic air-layer matrix ($\det T = 1$, $T_{11} = T_{22}$, TL = 0 dB,
hard-backed $|R| = 1$), synthetic round-trips that recover a known $r$, and
two-load recovery of an asymmetric reciprocal specimen.

See the [Materials guide](materials.md) for usage.

## References

- Cox, T. J., & D'Antonio, P. (2017). *Acoustic absorbers and diffusers:
  Theory, design and application* (3rd ed.). CRC Press.
  ISBN 978-1-4987-4099-9.
  [doi:10.1201/9781315369211](https://doi.org/10.1201/9781315369211).
  Absorber and diffuser measurement and design, by the authors behind the
  ISO 17497-2 diffusion-coefficient method.
- Allard, J. F., & Atalla, N. (2009). *Propagation of sound in porous media:
  Modelling sound absorbing materials* (2nd ed.). Wiley.
  ISBN 978-0-470-74661-5.
  [doi:10.1002/9780470747339](https://doi.org/10.1002/9780470747339).
  The porous-material theory linking the airflow-resistance and
  impedance-tube quantities of the characterisation section.
- International Organization for Standardization. (2004). *Acoustics —
  Sound-scattering properties of surfaces — Part 1: Measurement of the
  random-incidence scattering coefficient in a reverberation room*
  (ISO 17497-1:2004+A1:2014, the edition implemented here).
  [iso.org catalogue](https://www.iso.org/standard/31397.html).
  The turntable scattering-coefficient method and its Annex A uncertainty
  chain.
- International Organization for Standardization. (2012). *Acoustics —
  Sound-scattering properties of surfaces — Part 2: Measurement of the
  directional diffusion coefficient in a free field* (ISO 17497-2:2012).
  [iso.org catalogue](https://www.iso.org/standard/55293.html).
  The free-field directional diffusion coefficient and its solid-angle area
  weighting.
- International Organization for Standardization. (1998). *Acoustics —
  Determination of sound absorption coefficient and impedance in impedance
  tubes — Part 2: Transfer-function method* (ISO 10534-2:1998; adopted in
  Europe as EN ISO 10534-2:2001; since revised as
  [ISO 10534-2:2023](https://www.iso.org/standard/81294.html)).
  [iso.org catalogue](https://www.iso.org/standard/22851.html).
  The two-microphone transfer-function method of the impedance-tube
  section.
- ASTM International. (2019). *Standard test method for normal incidence
  determination of porous material acoustical properties based on the
  transfer matrix method* (ASTM E2611-19, the edition implemented here;
  since revised as [ASTM E2611-24](https://store.astm.org/e2611-24.html)).
  [ASTM store](https://store.astm.org/e2611-19.html).
  The four-microphone transfer-matrix decomposition and its transmission
  loss.
- International Organization for Standardization. (2018). *Acoustics —
  Determination of airflow resistance — Part 1: Static airflow method*
  (ISO 9053-1:2018).
  [iso.org catalogue](https://www.iso.org/standard/69869.html).
  The static airflow-resistance method and its reference velocity.

---


<!-- source: docs/theory-environment-transport.md | canonical: https://jmrplens.github.io/phonometry/reference/theory/environment-transport/ -->

# Theory: Environment and Transport

This page collects the theory behind outdoor and environmental noise: the whole-day rating descriptors and the impulsive-sound adjustment, atmospheric absorption, the general outdoor propagation method, occupational noise exposure with its uncertainty budget, and the sound power determination methods. It is part of the [theory reference](https://jmrplens.github.io/phonometry/reference/theory/).

## Environmental descriptors (ISO 1996-1)

The **day-evening-night level** $L_{den}$ (ISO 1996-1:2016, 3.6.4) is an energy average over the 24 h day with penalty weightings of **+5 dB for the evening** and **+10 dB for the night**:

$$
L_{den} = 10 \log_{10}\left\lbrace\frac{1}{24}\left[ t_d\ 10^{0.1 L_{day}} + t_e\ 10^{0.1 (L_{evening} + 5)} + t_n\ 10^{0.1 (L_{night} + 10)} \right]\right\rbrace
$$

with default period durations $(t_d, t_e, t_n) = (12, 4, 8)$ h; countries may define the periods differently (3.6.4 Note 1). The **day-night level** $L_{dn}$ (3.6.5) drops the evening period:

$$
L_{dn} = 10 \log_{10}\left\lbrace\frac{1}{24}\left[ t_d\ 10^{0.1 L_{day}} + t_n\ 10^{0.1 (L_{night} + 10)} \right]\right\rbrace, \qquad (t_d, t_n) = (15, 9)\ \text{h}
$$

Both are special cases of the **composite whole-day rating level** (6.5, generalizing Formulae 5–6), where each period $i$ contributes its rating level $L_i$ plus an adjustment $K_i$, weighted by its share of the day:

$$
L_R = 10 \log_{10}\left[ \sum_i \frac{h_i}{24}\ 10^{0.1 (L_i + K_i)} \right], \qquad \sum_i h_i = 24\ \text{h}
$$

The adjustments $K_i$ cover time-of-day penalties (ISO 1996-1 Table A.1: evening 5 dB, night 10 dB) as well as source-character adjustments (e.g. tonal penalties), which the ECMA-418-1 TNR/PR assessments can justify objectively.

See the [Levels guide](https://jmrplens.github.io/phonometry/guides/levels/) for usage.

## Impulsive-sound prominence (NT ACOU 112)

An impulse annoys beyond its energy, so environmental surveys after ISO 1996-2 penalize periods containing prominent impulsive sounds; NT ACOU 112:2002 makes that penalty objective. From the A-weighted, time-weighting-F level history of a single event, the onset rate (dB/s) and the level difference (dB) of the onset (which qualifies when steeper than 10 dB/s, clauses 4.5–4.7) predict the perceived prominence (clause 7, Formula 1):

$$
P = 3 \lg(\text{onset rate}) + 2 \lg(\text{level difference}),
$$

designed to peak around 15 for very sudden, loud impulses. The adjustment to the measurement-period level takes the governing (highest-$P$) impulse (clause 8, Formula 2):

$$
K_I = 1.8\ (P - 5)\ \text{dB} \quad (P > 5;\ \text{else } K_I = 0),
$$

and the whole-day rating level combines the adjusted periods energetically (clause 8, Note 1):

$$
L_{Ar,T} = 10 \lg\Big[ \frac{1}{T} \sum_N \Delta t_N\ 10^{(L_{Aeq,N} + K_{I,N})/10} \Big].
$$

$K_I$ is exactly the kind of source-character adjustment that enters the ISO 1996-1 composite rating level above. The anchors $P(1000\ \text{dB/s}, 30\ \text{dB}) = 9 + 2\lg 30 = 11.95$ and $K_I(P{=}10) = 9.0$ dB are reproduced exactly.

See the [Impulse Prominence guide](impulse-prominence.md) for usage.

## Outdoor propagation and occupational exposure (ISO 9613-1/2, ISO 9612)

### Atmospheric absorption (ISO 9613-1)

Air is a lossy medium: a propagating tone loses energy to shear viscosity and
heat conduction (classical and rotational losses, growing as $f^2$) and to the
**vibrational relaxation** of the oxygen and nitrogen molecules, each an energy
reservoir that resonates near a humidity- and temperature-dependent relaxation
frequency. ISO 9613-1:1993, Eq. (5) gives the pure-tone attenuation coefficient
$\alpha$ in decibels per metre:

$$
\alpha = 8.686\ f^2 \Big[ 1.84\times10^{-11} \big(p_a/p_r\big)^{-1} \big(T/T_0\big)^{1/2}
       + \big(T/T_0\big)^{-5/2} \big( 0.01275\ \tfrac{e^{-2239.1/T}}{f_{rO} + f^2/f_{rO}}
       + 0.1068\ \tfrac{e^{-3352.0/T}}{f_{rN} + f^2/f_{rN}} \big) \Big],
$$

with the oxygen and nitrogen relaxation frequencies $f_{rO}$, $f_{rN}$ of
Eq. (3)/(4), the reference conditions $T_0 = 293.15$ K, $p_r = 101.325$ kPa
(Clause 4.2) and the molar water-vapour concentration $h$ from the relative
humidity (Annex B). At low frequency $\alpha \propto f^2$; near each relaxation
frequency the corresponding term peaks and rolls off, which is why $\alpha$ rises
by two decades from 50 Hz to 10 kHz and why raising the humidity sweeps a peak
across the band. The library reproduces Table 1 to under 0.4 % (the standard's
own printed precision), well inside its stated $\pm 10$ %; passing
`exact_midband=True` snaps each frequency onto the exact midbands
$f_m = 1000 \cdot 10^{k/10}$ (Note 5) used to compute that table. The same
$\alpha$ is the only route to the ISO 354 power attenuation coefficient
$m = \alpha/(10 \lg e)$, exposed as `air_attenuation_m`.

### Outdoor propagation, general method (ISO 9613-2)

ISO 9613-2:1996 predicts the octave-band level at a receiver **downwind** of a
point source (or the equivalent moderate temperature inversion) as
$L_{fT}(DW) = L_W + D_c - A$ (Eq. (3)), where $D_c$ is the directivity correction
and $A$ is the octave-band attenuation, a sum of independent physical mechanisms
(Eq. (4)):

$$
A = A_{div} + A_{atm} + A_{gr} + A_{bar} + A_{misc}.
$$

The library implements the four general terms of Clause 7; the informative
$A_{misc}$ (foliage, industrial sites, housing) and reflections are left to the
caller. **Geometrical divergence** is spherical spreading from a point source,
$A_{div} = 20 \log_{10}(d/d_0) + 11$ dB with $d_0 = 1$ m (Eq. (7)): exactly
51 dB at 100 m, +6 dB per distance doubling. **Atmospheric absorption** is
$A_{atm} = \alpha\ d$ (Eq. (8)) with $\alpha$ the ISO 9613-1 coefficient above.
**Ground effect** $A_{gr} = A_s + A_r + A_m$ (Eq. (9)) sums a source, receiver and
middle region, each evaluated from the Table 3 functions $a'/b'/c'/d'$ and its
ground factor $G$ (0 hard, 1 porous); a negative $A_{gr}$ denotes a net gain from
the ground reflection. An alternative A-weighted-only form
$A_{gr} = 4.8 - (2 h_m/d)[17 + 300/d] \ge 0$ (Eq. (10)) is offered for porous
ground when only the A-weighted level matters, paired with the solid-angle index
$D_\Omega$ (Eq. (11)). **Screening** by a barrier is the diffraction insertion
loss

$$
D_z = 10 \log_{10}\big[ 3 + (C_2/\lambda)\ C_3\ z\ K_{met} \big] \quad\text{dB},
$$

(Eq. (14)) with $C_2 = 20$ (or 40 when ground reflections are handled by image
sources), $C_3 = 1$ for a single edge or Eq. (15) for a double edge, the
path-length difference $z = d_{ss} + d_{sr} - d$ (Eq. (16)/(17)), wavelength
$\lambda = 340/f$ and the meteorological factor $K_{met}$ (Eq. (18)); $D_z$ is
capped at 20 dB (single) or 25 dB (double). For a top-edge barrier the ground
effect of the screened path is folded into the screening term,
$A_{bar} = D_z - A_{gr} \ge 0$ (Eq. (12), Note 13); for a lateral (vertical-edge)
barrier $A_{bar} = D_z$ and the ground term is kept (Eq. (13)). The long-term
average level subtracts the meteorological correction $C_{met}$ (Eq. (6),
(21)/(22)). The method's stated accuracy is $\pm 1$ to $\pm 3$ dB for broadband
noise up to 1000 m (Table 5).

### Occupational noise exposure and uncertainty (ISO 9612)

ISO 9612:2009 is the engineering method (accuracy grade 2) for a worker's daily
noise exposure level $L_{EX,8h}$, normalised to a nominal 8 h day. Three
**measurement strategies** trade effort for representativeness. The *task-based*
method (Clause 9) splits the day into tasks, energy-averages $I \ge 3$ samples
per task (Eq. 7) and sums the task contributions
$L_{EX,8h,m} = L_{p,A,eqT,m} + 10 \log_{10}(T_m/T_0)$ energetically (Eq. 9/10).
The *job-based* method (Clause 10) energy-averages $N \ge 5$ random samples over a
homogeneous exposure group (Eq. 11) and normalises the effective-day duration
(Eq. 12); the *full-day* method (Clause 11) does the same arithmetic on whole-day
measurements (Eq. 13).

The **Annex C** uncertainty budget is normative. The combined standard
uncertainty is $u^2 = \sum c_i^2 u_i^2$ (C.1) and the expanded uncertainty is
$U = k\ u$ with $k = 1.65$ for a **one-sided** 95 % interval (Clause 14), so the
reported upper limit is $L_{EX,8h} + U$. The task and job methods differ in an
instructive way: the task noise-sampling uncertainty $u_{1a}$ divides the summed
squared deviations by $I(I-1)$ (the standard error of the mean, Eq. C.6)
whereas the job/full-day sampling uncertainty $u_1$ is the plain sample standard
deviation with denominator $N-1$ (Eq. C.12), so the same spread contributes more
in the job method (fewer, coarser samples). The task budget (Eq. C.3) adds the
sensitivity coefficients $c_{1a}$ (Eq. C.4) and $c_{1b}$ (Eq. C.5) and an optional
task-duration uncertainty $u_{1b}$ (Eq. C.7); the job/full-day budget (Eq. C.9)
reads $c_1 u_1$ from Table C.4 as a function of $(N, u_1)$ and adds the instrument
uncertainty $u_2$ (Table C.5) and microphone-position uncertainty $u_3 = 1.0$ dB
in quadrature. Peak levels $L_{p,Cpeak}$ are reported without an uncertainty:
Annex C provides no method for them (Table C.5, Note 1). The three worked
examples of Annexes D (task, $L_{EX,8h} = 84.3$ dB, $U = 2.7$ dB), E (job,
$88.1$ dB, $3.8$ dB) and F (full-day, $90.1$ dB, $3.4$ dB) are reproduced to
the standard's printed precision: every intermediate of Annex E is digit-exact,
and its final level differs only by the standard's own pre-rounding of the
effective-day level (see the [Occupational Noise Exposure guide](https://jmrplens.github.io/phonometry/guides/occupational-exposure/)).

See the [Outdoor Propagation guide](https://jmrplens.github.io/phonometry/guides/outdoor-propagation/) and the
[Occupational Noise Exposure guide](https://jmrplens.github.io/phonometry/guides/occupational-exposure/) for usage.

## Sound power determination (ISO 3744/3745/3746, ISO 3741, ISO 9614-2/3)

The sound power level $L_W = 10 \log_{10}(P/P_0)$ ($P_0 = 1$ pW) is an
*emission* quantity: unlike a pressure level it does not depend on the receiver
distance or the room. Three families of methods recover it.

### Enveloping-surface pressure (ISO 3744/3746)

Over a reflecting plane the free-field relation is simply
$L_W = \bar{L}_p + 10 \log_{10}(S/S_0)$: the mean-square pressure averaged over
an enveloping surface of area $S$, multiplied by $S$, is the radiated power.
Two corrections restore that idealisation. Uncorrelated **background noise**
adds its mean square to the source's, so with the margin
$\Delta L_p = L_{ST} - L_{bg}$ the source-only level is recovered by subtracting
$K_1 = -10 \log_{10}(1 - 10^{-\Delta L_p/10})$ (from $p_{src}^2 = p_{ST}^2 (1 - 10^{-\Delta L_p/10})$).
The **reverberant field** of a non-anechoic room adds a near-uniform energy
density $4P/(A c)$ to the direct $P/(S c)$, so the surface level exceeds the
free-field value by their ratio, $K_2 = 10 \log_{10}(1 + 4 S/A)$, with $A$ the
room's equivalent absorption area. The surface area is the closed form of the
geometry: a hemisphere $S = 2 \pi r^2$ over one reflecting plane (halved and
quartered for two and three planes), a one-plane box $S = 4(ab + bc + ca)$ with
$a = 0.5\ l_1 + d$, $b = 0.5\ l_2 + d$, $c = l_3 + d$. ISO 3746 (survey) shares
the maths with looser criteria. The expanded uncertainty is
$U = 2 \sqrt{\sigma_{R0}^2 + \sigma_{omc}^2}$.

### Precision grade in anechoic rooms (ISO 3745)

ISO 3745:2012 is the grade-1 (precision) sibling: a qualified anechoic or
hemi-anechoic room removes the reverberant field, so there is no $K_2$ term and
the corrections become meteorological. The power level is
$L_W = \bar{L}_p + 10 \lg(S/S_0) + C_1 + C_2 + C_3$ (Eq. 14/15) over a full
sphere $S = 4 \pi r^2$ or hemisphere $S = 2 \pi r^2$, with the background
correction $K_{1i} = -10 \lg(1 - 10^{-0.1 \Delta L_{pi}})$ applied per
microphone position *before* the energy average (Eq. 11); no correction is
needed above a 15 dB margin, and below 10 dB (250 Hz – 5 kHz) or 6 dB (edge
bands) the correction is clamped and the result flagged as an upper bound
(clause 9.4.2). The meteorological terms are
$C_1 = -10 \lg(p_s/p_{s0}) + 5 \lg[(273 + \theta)/\theta_0]$ and
$C_2 = -10 \lg(p_s/p_{s0}) + 15 \lg[(273 + \theta)/\theta_1]$ with
$\theta_0 = 314$ K, $\theta_1 = 296$ K: at the 23 °C / 101.325 kPa reference
$C_2 = 0$ exactly and $C_1 = -0.128$ dB; and
$C_3 = A_0 (1.0053 - 0.0012 A_0)^{1.6}$ with $A_0 = a(f)\ r$ restores the
ISO 9613-1 air absorption over the measurement radius. The Annex D/E
microphone arrays are built in as digit-exact coordinate tables (40 equal-area
positions; the mirror set 21–40 is added when the band-SPL spread exceeds
$N_M/2$, clause 9.3.2), and the same positions yield the directivity index
$DI_i = L_{pi} - \bar{L}_p$ (Eq. 21). The clause 10.5 uncertainty example,
$U = 2\sqrt{0.5^2 + 2.0^2} = 4.12$ dB, is reproduced, along with the Table 2/3
per-band $\sigma_{R0}$ values.

### Reverberation room (ISO 3741)

In a qualified diffuse field the steady energy density $w = 4P/(A c)$ ties the
power to the room absorption, giving $L_W = \bar{L}_p + 10 \log_{10}(A/A_0) - 6$
plus higher-order corrections, with $A = (55.26/c)(V/T_{60})$ and
$c = 20.05 \sqrt{273 + \theta}$. The **Waterhouse correction**
$10 \log_{10}(1 + S c/(8 V f))$ compensates the extra energy stored in the
boundary layer that interior microphones miss ($S c/(8 V f) = S \lambda/(8 V)$,
so it fades as frequency rises); the $4.34\ A/S$ term is the mean-free-path air
correction, and $C_1$, $C_2$ carry the result to the reference meteorological
conditions (23 °C, 101.325 kPa). The **comparison method** subtracts a
reference source of known power measured in the same room,
$L_W = L_{W(\text{RSS})} + (\bar{L}_p - \bar{L}_{p,\text{RSS}} + C_2)$, so the
absorption-area, Waterhouse and $C_1$ terms cancel and the room need not be
characterised.

### Intensity scanning (ISO 9614-2)

Sound intensity is the net energy flux $\vec{I} = \overline{p\ \vec{u}}$, so by
the divergence theorem the power through a closed surface is
$P = \sum_i \langle I_{n,i} \rangle\ S_i$. A steady source *outside* the surface
contributes zero net flux (its energy enters and leaves), which is why
intensity rejects stationary background noise, but it can still drive a band's
$P$ negative, in which case that band is not determinable. Two normative field
indicators gate validity: the surface pressure-intensity indicator
$F_{pI} = [L_p] - L_W + 10 \log_{10}(S/S_0)$ (reactivity) and the
negative-partial-power indicator
$F_{+/-} = 10 \log_{10}(\sum_i \lvert P_i \rvert / \lvert \sum_i P_i \rvert)$
(recirculation), together with the probe's dynamic capability
$L_d = \delta_{pI0} - K$ ($K = 10$ dB grade 2, 7 dB grade 3), which must exceed
$F_{pI}$. A band earns the engineering grade when $L_d > F_{pI}$, $F_{+/-} \le 3$ dB
and the two repeated sweeps agree within the Table 2 limit.

### Precision intensity scanning (ISO 9614-3)

ISO 9614-3:2002 upgrades the scanning method to precision grade with a tighter
indicator machinery. The partial powers $P_i = I_{n,i} S_i$ (Eq. 5) sum as
before, but validity now rests on the signed and unsigned pressure-intensity
indicators $F_{pIn} = \bar{L}_p - L_{In}$ (Eqs. B.3/B.6, the F2/F3 of
ISO 9614-1) and the normalized intensity non-uniformity $F_S$ (Eq. B.8),
through five acceptance criteria (Annex C): scan repeatability
$|L_{In}(1) - L_{In}(2)| \le s/2$ (C.1), dynamic capability
$L_d = \delta_{pI0} - K \ge F_{pIn}(\text{signed})$ with the precision
bias-error factor $K = 10$ dB (C.2),
$F_{pIn}(\text{signed}) - F_{pIn}(\text{unsigned}) \le 3$ dB (C.3),
$F_S \le 2$ (C.4) and the scan-density convergence
$0.83 \le F_S(1)/F_S(2) \le 1.2$ (C.5). Eq. 10 normalizes the result to the
reference meteorological conditions,
$L_{W0} = L_W - 15 \lg[(B/101325) \cdot 296.15/(273.15 + \theta)]$. Bands whose
net power is negative are not determinable (clause 9.2) and are flagged. A
uniform normal intensity recovers the power exactly (100 µW over 3.75 m² →
80.0 dB re 1 pW), independent of how the surface is segmented.

See the [Sound Power guide](https://jmrplens.github.io/phonometry/guides/sound-power/) for usage.

## References

- Salomons, E. M. (2001). *Computational atmospheric acoustics*. Kluwer
  Academic Publishers. ISBN 978-1-4020-0390-5.
  [doi:10.1007/978-94-010-0660-6](https://doi.org/10.1007/978-94-010-0660-6).
  The wave-based outdoor propagation theory behind the engineering
  approximations of the ISO 9613-2 section.
- Attenborough, K., & Van Renterghem, T. (2021). *Predicting outdoor sound*
  (2nd ed.). CRC Press.
  [doi:10.1201/9780429470806](https://doi.org/10.1201/9780429470806).
  The ground impedance and meteorological effects underlying the
  ground-effect and barrier terms.
- Maekawa, Z. (1968). Noise reduction by screens. *Applied Acoustics*, 1(3),
  157-173.
  [doi:10.1016/0003-682X(68)90020-0](https://doi.org/10.1016/0003-682X(68)90020-0).
  The screen-attenuation chart that the barrier insertion-loss formulas
  descend from.
- Fahy, F. J. (1995). *Sound intensity* (2nd ed.). E&FN Spon.
  ISBN 978-0-419-19810-9.
  [doi:10.4324/9780203475386](https://doi.org/10.4324/9780203475386).
  The intensity physics behind the scanning methods: active intensity and
  the p-p error budget.
- International Organization for Standardization. (2016). *Acoustics —
  Description, measurement and assessment of environmental noise — Part 1:
  Basic quantities and assessment procedures* (ISO 1996-1:2016).
  [iso.org catalogue](https://www.iso.org/standard/59765.html).
  The composite rating level and Table A.1 adjustments of the descriptors
  section.
- Nordtest. (2002). *Acoustics: Prominence of impulsive sounds and for
  adjustment of LAeq* (Nordtest Method NT ACOU 112).
  [nordtest.info](https://www.nordtest.info/wp/2002/05/01/acoustics-prominence-of-impulsive-sounds-and-for-adjustment-of-laeq-nt-acou-112/).
  The onset-rate prominence and LAeq adjustment of the impulsive-sound
  section.
- International Organization for Standardization. (2022). *Acoustics —
  Description, measurement and assessment of environmental noise — Part 3:
  Objective method for the measurement of prominence of impulsive sounds and
  for adjustment of LAeq* (ISO/PAS 1996-3:2022).
  [iso.org catalogue](https://www.iso.org/standard/77035.html).
  The ISO successor built on the NT ACOU 112 prominence.
- International Organization for Standardization. (1993). *Acoustics —
  Attenuation of sound during propagation outdoors — Part 1: Calculation of
  the absorption of sound by the atmosphere* (ISO 9613-1:1993).
  [iso.org catalogue](https://www.iso.org/standard/17426.html).
  The pure-tone attenuation coefficient and its relaxation physics.
- International Organization for Standardization. (1996). *Acoustics —
  Attenuation of sound during propagation outdoors — Part 2: General method
  of calculation* (ISO 9613-2:1996; revised in 2024, the 1996 method is the
  implemented one).
  [iso.org catalogue](https://www.iso.org/standard/20649.html).
  The attenuation chain (divergence, air, ground, barrier) of the
  general-method section.
- International Organization for Standardization. (2009). *Acoustics —
  Determination of occupational noise exposure — Engineering method*
  (ISO 9612:2009). [iso.org catalogue](https://www.iso.org/standard/41718.html).
  The three measurement strategies and the Annex C uncertainty budget of
  the occupational section.
- International Organization for Standardization. (2010). *Acoustics —
  Determination of sound power levels and sound energy levels of noise
  sources using sound pressure — Engineering methods for an essentially free
  field over a reflecting plane* (ISO 3744:2010).
  [iso.org catalogue](https://www.iso.org/standard/52055.html).
  The enveloping-surface method with its background and reverberant-field
  corrections.
- International Organization for Standardization. (2012). *Acoustics —
  Determination of sound power levels and sound energy levels of noise
  sources using sound pressure — Precision methods for anechoic rooms and
  hemi-anechoic rooms* (ISO 3745:2012).
  [iso.org catalogue](https://www.iso.org/standard/45362.html).
  The precision anechoic method with its meteorological corrections and
  microphone arrays.
- International Organization for Standardization. (2010). *Acoustics —
  Determination of sound power levels and sound energy levels of noise
  sources using sound pressure — Precision methods for reverberation test
  rooms* (ISO 3741:2010).
  [iso.org catalogue](https://www.iso.org/standard/52053.html).
  The diffuse-field method with the Waterhouse correction and the
  comparison method.
- International Organization for Standardization. (1993). *Acoustics —
  Determination of sound power levels of noise sources using sound
  intensity — Part 1: Measurement at discrete points* (ISO 9614-1:1993).
  [iso.org catalogue](https://www.iso.org/standard/17427.html).
  The field indicators and dynamic-capability criterion shared by the
  intensity-scanning methods.

---


<!-- source: docs/theory-vibration.md | canonical: https://jmrplens.github.io/phonometry/reference/theory/vibration/ -->

# Theory: Vibration

This page collects the theory behind human vibration: the ISO 8041-1 frequency weightings, the whole-body and hand-arm metrics of ISO 2631-1 and ISO 5349, the action and limit values of Directive 2002/44/EC, and the ISO 2631-5 multiple-shock spinal model. It is part of the [theory reference](https://jmrplens.github.io/phonometry/reference/theory/).

## Human vibration (ISO 8041-1, ISO 2631-1/2, ISO 5349-1/2, Directive 2002/44/EC)

Human response to vibration depends on frequency, axis and body part, so
acceleration is filtered by the frequency weightings of ISO 8041-1:2017 before
any metric. Each weighting is the analog cascade
$H(s) = H_h(s) H_l(s) H_t(s) H_s(s)$ (Formula 5): two-pole Butterworth
band-limiting high-pass and low-pass stages (Formulae 1/2), an
acceleration–velocity transition (Formula 3, carrying the only non-unity gain,
$K = 1.024$ for Wb) and an upward step (Formula 4), with the Table 3 corner
frequencies and Q factors; a corner at infinity collapses its stage to unity
(Table 3 NOTEs). Wk (vertical whole-body) and Wd (horizontal) of
ISO 2631-1, Wm (buildings, ISO 2631-2), Wb (rail, ISO 2631-4), Wc/We/Wj
(seat-back, rotational, head) and Wh (hand-arm, ISO 5349-1) plus Wf (motion
sickness) are all implemented from the exact cascade (the filter is applied
as the exact complex response via FFT, magnitude *and* phase, not a
bilinear-warped digital approximation) and the ISO 8041-1 Annex B design-goal
tables (B.1–B.9) are reproduced to 0.1 %.

The weighted metrics follow ISO 2631-1:1997: running rms with linear or
exponential integration (Eqs. 2/3), **MTVV** as its maximum (Eq. 4), the
fourth-power **VDV** $= (\int a_w^4\, dt)^{1/4}$ in m/s^1.75 (Eq. 5), the crest
factor with the basic method deemed adequate up to 9 (clause 6.2), and the
vibration total value $a_v = \sqrt{\sum_j k_j^2 a_{wj}^2}$ (Eq. 10). Hand-arm
exposure follows ISO 5349-1:2001: $a_{hv}$ (Eq. 1, all $k = 1$), daily
exposure $A(8) = a_{hv} \sqrt{T/T_0}$ with $T_0 = 8$ h (Eq. 2), partial
exposures combined in quadrature (ISO 5349-2:2001, Eqs. 1–3), and the Annex C
vascular-risk model $D_y = 31.8\ A(8)^{-1.06}$ for the years to 10 %
white-finger prevalence. The Directive 2002/44/EC action and limit values are
built in: hand-arm $A(8)$ 2.5/5.0 m/s², whole-body $A(8)$ 0.5/1.15 m/s² or
VDV 9.1/21.0 m/s^1.75 (Article 3). The ISO 5349-2 worked examples are
reproduced (E.2.1: 7.4 m/s² for 2.5 h → $A(8) = 4.1$ m/s²; E.3 forestry,
three tools → 3.6 m/s²), as are the ISO 5349-1 Table C.1 exposure-duration
rows.

### Multiple shocks (ISO 2631-5)

Repeated shocks damage the lumbar spine through peak compression rather than
average energy, so ISO 2631-5:2018 replaces the Wk weighting with the
seat-to-spine transfer function of clause 5.2 (Formula 1: one complex zero and
six complex pole pairs, unity at DC, resonance near 5 Hz,
$|H| \approx 1.54$ at 5 Hz) and accumulates the positive spinal-response peaks with a
sixth-power (Palmgren-Miner) dose (clause 5.3, Formulae 3/4):

$$
D_z = 1.07 \left( \sum_i A_{z,i}^6 \right)^{1/6}, \qquad
D_{zd} = D_z\ (t_d / t_m)^{1/6}.
$$

Annex C converts the daily dose to a compressive stress $S_d = m_z D_{zd}$
($m_z = 0.029/0.025$ MPa per m/s² for the 82 kg male / 64 kg female), tracks
the age-declining ultimate strength $S_u = 6.75 - S_{age}(b + i)$ and forms
the cumulative stress variable $R$ (Formulae C.3/C.4), mapped to an injury
probability by the Table C.1 Weibull law $\Pi = 1 - e^{-(R/\alpha)^\beta}$.
The spinal filter is evaluated analytically in the frequency domain and
validated against the Annex D 256 Hz digital-filter tabulation within the
clause 5.2 tolerance; the Annex C worked example (five 40 m/s² shocks per day
over 20 years) is reproduced: $D_{zd} = 55.97$ m/s², $R = 1.22$,
$\Pi = 0.37$. The Annex A finite-element spinal model (distributed by ISO as
separate software) is out of scope.

See the [Human Vibration guide](human-vibration.md) and the
[Multiple-Shock Vibration guide](multiple-shock-vibration.md) for usage.

## Point mobilities and radiation efficiency (Cremer 5, Hopkins 2.9)

The vibrational power a point force injects into a structure is
$W = \tfrac12 |F|^2\,\mathrm{Re}\{Y\}$ (Cremer Eq. 5.23), so the driving-point
**mobility** $Y$ (the reciprocal of the impedance) governs how much energy the
structure absorbs. For infinite structures these are closed forms (Cremer
Table 5.1): an infinite thin plate is a pure resistance $Z = 8\sqrt{B'\,m''}$
(real, frequency independent, with $B'$ the bending stiffness per unit width and
$m''$ the mass per unit area), an infinite beam has
$Y = (1-\mathrm{j})/(4 m' c_B)$ (a 45-degree phase, falling as $\omega^{-1/2}$
through the bending wave speed $c_B$), and a longitudinal rod has
$Z = \rho c_L S$. These supply the receiver mobility EN 12354-5 needs when no
measurement exists, and are the theoretical companions of the measured ISO 7626
mobilities. How efficiently a bending plate then radiates the airborne power is
its **radiation efficiency** $\sigma$: below the critical frequency it radiates
weakly (edge and corner modes), and above it $\sigma \to (1 - f_c/f)^{-1/2} \to 1$
(Leppington/Maidanik, Hopkins Eqs 2.227-2.230). Because $\sigma$ is exactly the
radiation factor $\varepsilon$ of ISO 7849, predicting it closes the sound-power-
from-vibration chain without a power measurement, and it drives the resonant
transmission path of the
[panel sound insulation theory](https://jmrplens.github.io/phonometry/reference/theory/rooms-buildings/). Both are clean-room
from Cremer, Heckl & Petersson (2005) and Hopkins (2007).

See the [Predicting Panel Sound Insulation guide](https://jmrplens.github.io/phonometry/guides/panel-sound-insulation/) for
usage.

## References

- Griffin, M. J. (1996). *Handbook of human vibration*. Academic Press.
  ISBN 978-0-12-303041-2.
  [Publisher page](https://shop.elsevier.com/books/handbook-of-human-vibration/griffin/978-0-12-303041-2).
  The biodynamic and health-effect evidence behind the ISO 8041-1 weightings,
  the rms/MTVV/VDV dose measures and the spinal-injury rationale of the
  multiple-shock model.
- Mansfield, N. J. (2004). *Human response to vibration*. CRC Press.
  ISBN 978-0-415-28239-0.
  [Publisher page](https://www.routledge.com/Human-Response-to-Vibration/Mansfield/p/book/9780415282390).
  A compact modern walkthrough of the ISO 2631-1 whole-body and ISO 5349
  hand-arm evaluation chains summarised on this page.

---


<!-- source: docs/why-phonometry.md | canonical: https://jmrplens.github.io/phonometry/reference/why-phonometry/ -->

# Why phonometry

phonometry is a standards-based acoustic measurement toolkit. Its
differentiator is not the list of features but how they are built: every
metric is implemented from the governing standard's text, and the standard's
own reference values and acceptance limits are transcribed into the test
suite and enforced in CI. This page explains that approach with a concrete
case study (time weighting under **IEC 61672-1:2013**) and summarizes what
is conformance-tested today. The time-weighting analysis was originally
published in [issue #38](https://github.com/jmrplens/phonometry/issues/38).

## Design philosophy: a case study in time weighting

Standard time weighting is defined as a **continuous function of time** via the
differential equation

$$
\tau \frac{dy(t)}{dt} + y(t) = x^2(t)
$$

which corresponds to a stable first-order low-pass filter with a pole in the
left half-plane ($s = -1/\tau$).

| | phonometry | python-acoustics |
| :--- | :--- | :--- |
| Output | Continuous time-weighted envelope (one value per sample) | Stepped output (one value every τ seconds) |
| Input units | Raw sound pressure (Pa); squared internally | Energetic quantity (Pa²) expected as input |
| Filter | Stable exponential averaging (pole at $s=-1/\tau$) | Theoretically unstable design (pole in the right half-plane) stabilized by resetting the filter state every τ seconds |
| Behavior | True IEC time weighting | Closer to a block integrator ($L_{eq,\tau}$) |

A pole on the negative real axis corresponds to a decaying exponential impulse
response ($h(t) \propto e^{-t/\tau}$), exactly what "exponential time
weighting" means: past events are forgotten exponentially. A pole on the
positive real axis grows without bound; block-resetting hides this but changes
the measurement's nature.

## Verification against IEC 61672-1 (tone bursts)

The rigorous test for time weighting is the **Tone Burst Response**
(IEC 61672-1, Table 4), using a 4 kHz sine burst referenced to the steady-state
level.

**phonometry results (FAST):**

| Burst duration | IEC target (dB) | phonometry (dB) | Error (dB) | Status |
| :--- | :--- | :--- | :--- | :--- |
| 200 ms | −1.0 | −0.98 | +0.02 | ✅ PASS |
| 50 ms | −4.8 | −4.82 | −0.02 | ✅ PASS |
| 10 ms | −11.1 | −11.14 | −0.04 | ✅ PASS |
| 1 ms | −20.9 | −20.99 | −0.09 | ✅ PASS |

**python-acoustics results (FAST, squared signal passed as required by that
library):**

| Burst duration | IEC target (dB) | python-acoustics (dB) | Error (dB) | Status |
| :--- | :--- | :--- | :--- | :--- |
| 200 ms | −1.0 | −0.97 | +0.03 | ✅ PASS |
| 50 ms | −4.8 | −3.93 | **+0.87** | ⚠️ FAIL |
| 10 ms | −11.1 | −10.90 | +0.20 | ✅ PASS |
| 1 ms | −20.9 | −20.90 | +0.00 | ✅ PASS |

phonometry maintains high precision across all test cases. The block-based
approach deviates significantly (> 0.8 dB) for the 50 ms burst because 125 ms
blocks cannot resolve short transient events accurately; the result depends on
how the burst aligns with the block boundaries.

<picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/tone_burst_iec_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/tone_burst_iec.svg" alt="Fast envelope responses to 200, 50 and 10 ms tone bursts peaking exactly at the IEC 61672-1 Table 4 reference values" width="80%"></picture>

*Measured Fast envelopes (blue) matching the Table 4 reference values
(dashed) within 0.1 dB for 200/50/10 ms bursts.*

<details>
<summary>Show the code for this figure</summary>

```python
import matplotlib.pyplot as plt
import numpy as np
from phonometry import metrology

# 4 kHz tone bursts vs the IEC 61672-1 Table 4 reference maxima (FAST)
fs = 48000
t = np.arange(2 * fs) / fs
steady = np.sin(2 * np.pi * 4000 * t)
ref = metrology.time_weighting(steady, fs, mode="fast")[int(1.5 * fs):].mean()

fig, axes = plt.subplots(1, 3, figsize=(12, 4), sharey=True)
for ax, (duration, target) in zip(axes, [(0.2, -1.0), (0.05, -4.8), (0.01, -11.1)]):
    burst = np.zeros_like(t)
    start, n = int(0.5 * fs), round(duration * fs)
    burst[start:start + n] = steady[start:start + n]
    env = metrology.time_weighting(burst, fs, mode="fast")
    ax.plot(t, 10 * np.log10(np.maximum(env / ref, 1e-6)), label="FAST envelope")
    ax.axhline(target, linestyle="--", label=f"IEC target {target} dB")
    ax.set(xlim=(0.4, 1.4), ylim=(-30, 3), xlabel="Time [s]",
           title=f"{duration * 1000:g} ms burst")
    ax.legend()
axes[0].set_ylabel("Level re steady state [dB]")
plt.show()
```

</details>

## What this means in practice

- If you need **standard-compliant Fast/Slow/Impulse envelopes** (sound level
  meter behavior, one level per sample), use phonometry's
  [`time_weighting`](https://jmrplens.github.io/phonometry/guides/time-weighting/).
- If you need **block-averaged Leq per interval**, that is a different, equally
  valid metric; you can compute it with [`leq`](https://jmrplens.github.io/phonometry/guides/levels/) over consecutive
  slices.
- Both approaches are useful; they just answer different questions. The
  discrepancy reported in issue #38 comes from comparing a continuous envelope
  against a block integrator, not from an implementation error.

## Conformance testing across the library

The tone-burst case above is not an isolated check. For each standard the
library implements, the reference values and acceptance limits are transcribed
from the official text into the test suite, so any regression fails CI:

| Standard | What is verified | Test file |
| :--- | :--- | :--- |
| IEC 61672-1:2013 Table 3 | A/C/Z weighting at all 34 nominal frequencies, class 1 limits, at 48 and 96 kHz | `tests/test_iec_weighting_table3.py` |
| IEC 61672-1:2013 Table 4 | F/S tone-burst responses (1 s to 1 ms) and the LAE column for `sel()` | `tests/test_iec_compliance.py` |
| IEC 61672-1:2013 Table 5 | `lc_peak()` one-cycle/half-cycle peak responses, class 1 limits | `tests/test_levels.py` |
| IEC 61260-1:2014 Table 1 | Filter-bank class 1/2 acceptance limits via `verify_filter_class()` | `tests/test_compliance.py` |
| ISO 7196:1995 Table 2 | G weighting (infrasound) at every nominal response value, 0.25–315 Hz | `tests/test_g_weighting.py` |
| ISO 226:2023 Annex B | Equal-loudness contours, loudness levels and hearing threshold against the Annex B tables | `tests/test_loudness_contours.py` |
| ECMA-418-1:2024 | TNR/PR tone prominence: critical bandwidths, proximity spacing and prominence criteria against the worked examples in clauses 10–12 | `tests/test_tonality.py` |
| ISO 1996-1:2016 | `lden()`, `ldn()` and `composite_rating_level()` against hand-computed formula values | `tests/test_environmental.py` |
| IEC 60942:2017 Table 2 | Calibrator short-term stability limits (frequency-dependent, class 1) in `sensitivity()` | `tests/test_calibration_validation.py` |

The full numerical report (the expected value and the value the library
computes for every check, regenerated on every pull request) is published
as [CONFORMANCE.md](CONFORMANCE.md).

Beyond IEC 61252-style noise dose (`sound_exposure()`, `lex_8h()`), the same
standards-first mindset shows up in the numerics: filter banks place their
−3 dB points on the **ANSI S1.11 / IEC 61260-1** band edges for every
architecture (including Chebyshev II and Bessel, where scipy's raw
parametrization would not), and A/C weighting stays within class 1 tolerances
up to 16 kHz at common audio rates via internal oversampling (see
[Frequency Weighting](https://jmrplens.github.io/phonometry/guides/weighting/)).

## Where phonometry fits in the Python ecosystem

- **python-acoustics** was archived in February 2024 and is no longer
  maintained. Its comparison above reflects the last released code.
- **acoustic-toolbox**, the community successor to python-acoustics, depends
  on phonometry for its weighted level computations rather than reimplementing
  them.
- **MoSQITo** focuses on psychoacoustic sound-quality metrics (loudness,
  sharpness, roughness). It complements rather than overlaps phonometry:
  it does not cover sound level metrology (weighting filters, ballistics,
  Leq/SEL/Lden, calibration) and does not claim conformance testing against
  the standards' tolerance tables.

If your work needs numbers you can defend against a standard's tolerance
table, whether for measurement reports, environmental assessments or
instrument cross-checks, that verification layer is what phonometry is for. The
sources behind it are collected in the [Bibliography](references.md),
every entry with a verified DOI or official publisher link.

---
