← [Documentation index](README.md)

# 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](occupational-exposure.md).

## 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](tone-prominence.md) 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](calibration.md) to convert digital units to physical
SPL, and [Time Weighting](time-weighting.md) for the envelope details. The
ISO 9612 occupational strategies continue in
[Occupational Noise Exposure](occupational-exposure.md), the ECMA-418-1
tonal-prominence verdicts in [Prominent Discrete Tones](tone-prominence.md),
and the ISO 226 equal-loudness contours live with the perception metrics in
[Loudness](loudness.md).

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