Multichannel and Performance
Standards: IEC 61260IEC 61672Key references: Bendat & Piersol 2010
Most real measurement sessions produce more than one channel: the two ears
of a dummy head, the microphone pair of an intensity probe, the several
positions of a room survey, the capsules of a beamforming array. The
convention for all of them is one array of shape (channels, samples)
processed in a single call: every function runs along the last (time) axis
and preserves the leading channel axis, so each row is analyzed exactly as
if it were the only one.
That per-channel guarantee is normative, not just convenient. The band filters applied to each row are the IEC 61260-1 designs and the weightings and detector ballistics are the IEC 61672-1 ones, unchanged from the single-channel path; the vectorization batches the arithmetic across rows (one filter design, one SciPy call) and never mixes them. This is the textbook procedure for multiple data records (Bendat & Piersol 2010, §10.4.2): analyze each record individually first, and compute anything joint as a separate, explicit step.
Use this page when your channels are parallel recordings on the same clock and you want per-channel spectra or levels. When the question is between channels (what is the delay from A to B, how much of B is explained by A), that is cross-channel analysis: see Correlation and delay and Multiple and partial coherence, the implementations of the Bendat & Piersol cross-correlation and multiple-input models.
A stereo analysis in one call
Section titled “A stereo analysis in one call”One octave_filter call, two channels: pink noise on the left, a logarithmic
sweep on the right. Both come back flat in one-third-octave bands — pink noise
because its spectrum has constant energy per fractional-octave band, the
sweep because it spends proportionally equal time in each — and neither row is
affected by the other.
Show the code for this figure
import matplotlib.pyplot as pltimport numpy as npfrom scipy.signal import chirpfrom phonometry import filters
# Stereo test signal: pink noise left, logarithmic sine sweep rightfs, duration = 48000, 5t = 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 noiseleft = 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 = filters.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()Read the figure row by row before moving on. The roll-offs at the two ends of the right row are the sweep’s own 50 Hz and 10 kHz limits, not a filter effect. And two conclusions follow from the flatness. A flat fractional-octave spectrum corresponds to a power spectral density, so white noise does not look flat in this display: it rises at 3 dB per octave, because each band is 26 % wider than the one below. And two signals with completely different time structure — a stationary noise and a sweep that is at 50 Hz at one moment and 10 kHz five seconds later — map to the same spectrum, which is why the band levels of this page answer “how much energy per band” and never “when”. The octave spectrogram of Integrated and Statistical Levels is the tool that restores the time axis.
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.
import numpy as npfrom phonometry import filters
# Two calibrated channels in Pa so the guide runs standalonefs = 48000t = np.arange(fs) / fsleft = 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 = filters.octave_filter(stereo, fs, fraction=3)# spl has shape (2, n_bands): one row per channelAccepted shapes, at a glance
Section titled “Accepted shapes, at a glance”| 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. Convention: channels
first, like most DSP code (soundfile returns (n, ch): transpose with
x.T). What that buys, and what it does not, is the subject of
Performance below.
Per-channel semantics
Section titled “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 for why). - One calibration factor means one sensitivity. The scalar factor —
calibration=LevelCalibration(factor=...)on a filter bank,calibration_factor=onleq,laeq,ln_levels,lc_peakandsel— 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 the factor at its default 1.0. - 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.
The two lines of code, on a realistic survey. Where the positions agree the two averages are the same number; where they disagree — the modal region, which is exactly where a room survey has several positions in the first place — the arithmetic mean of the decibels under-reads by up to 3.8 dB. The error is never negative, so it does not wash out over more positions.
Show the code for this figure
# Five positions of one room, band levels as a (5, bands) array. Here the# positions are synthesized; in the field they are five rows of one capture.rng = np.random.default_rng(7)survey = np.stack([0.02 * (1 + 0.3 * rng.standard_normal()) * rng.standard_normal(2 * fs) for _ in range(5)])survey_levels, _ = filters.octave_filter(survey, fs, fraction=3)
energetic = 10 * np.log10(np.mean(10 ** (survey_levels / 10), axis=0))arithmetic = np.mean(survey_levels, axis=0)print(f"worst under-read {np.max(energetic - arithmetic):.2f} dB")# worst under-read 2.12 dBCalibrating an array
Section titled “Calibrating an array”The sensitivity vector in the bullet above is not arithmetic, it is a procedure, and three things decide whether the result is right.
# One sensitivity per capsule, in Pa per digital unit. Each element comes from# metrology.sensitivity(cal_tone[i], target_spl=94.0, fs=fs) on a calibration# capture in which the calibrator was coupled to capsule i and nothing else# was touched.factors = np.array([0.01021, 0.00984, 0.01007, 0.00998])survey = np.stack([left, right, left, right]) # your (4, n) capturecalibrated = survey * factors[:, None]spl, freq = filters.octave_filter(calibrated, fs, fraction=3)Lock the gains before the first tone and do not touch them again, because every factor is only valid for the gain it was measured at. Couple the calibrator to each capsule in turn rather than reusing one factor for the array: capsules of the same model differ by a few tenths of a decibel, and that difference lands directly in a position-averaged level. Repeat the pass at the end of the session and treat the largest per-channel difference as the array’s drift bound. Then two invariants that no downstream check can recover if they are wrong: every channel must come from one converter clock (two interfaces are two clocks, and a slow relative drift destroys any between-channel analysis), and the row-to-position map must be written down at capture time, because a swapped pair produces perfectly valid levels attributed to the wrong positions.
The shape table above is the abstraction of that picture: each row of x is
one microphone, and the mapping from a row index back to a position on the
floor exists only in your notes.
Performance: Vectorization and caching
Section titled “Performance: Vectorization and caching”Start with what batching does not buy. The arithmetic scales linearly with channels, because each channel has to be filtered by each band, so eight channels cost eight channels however the call is written. Measured at 48 kHz on 5 s per channel through one reused one-third-octave bank:
| Channels | One call [ms] | Per-channel loop [ms] | Ratio |
|---|---|---|---|
| 1 | 193 | 173 | 0.90 |
| 2 | 337 | 352 | 1.04 |
| 8 | 1394 | 1721 | 1.23 |
| 32 | 5646 | 5208 | 0.92 |
The ratio hovers around one, which is the honest answer: what batching removes is per-call Python overhead and the temptation to redesign the bank, and it is the redesign that actually costs — building a fresh one-third-octave bank takes about 33 ms, an order of magnitude more than a single band-filtering pass over a short frame.
So the cost model is simple. One SOS filtering pass per band per channel
dominates, which means the time scales with the number of bands (a
one-third-octave bank is three times a one-octave one), with the record length
and with the channel count — and with nothing else. The actionable rules that
follow: build one OctaveFilterBank and reuse it; pass all channels in one
array for clarity rather than for speed; and when a run is too slow, reach for
fraction, limits and record length, because those are the three terms in
the model.
OctaveFilterBank is the tool for repeated or streaming analysis: one bank
designs its filters once and applies them to every frame, and NumPy
broadcasting covers all channels of a frame in one filtering call per band,
with no Python loop over channels.
import numpy as npfrom phonometry import filters
# Two calibrated channels in Pa so the guide runs standalonefs = 48000t = np.arange(fs) / fsleft = 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 = filters.OctaveFilterBank( fs=48000, fraction=3, design=filters.FilterDesign(filter_type='butter'))
# Access computed properties# bank.freq (center), bank.freq_d (lower), bank.freq_u (upper), bank.sos (coefficients)
# Process multiple signals efficientlystream = [stereo] # your sequence of multichannel framesfor 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.OctaveFilterBankgives 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).
- Optional numba: the
impulsetime weighting kernel is JIT-compiled when numba is installed (pip install phonometry[perf]).
What this guide covers
Section titled “What this guide covers”Covered
IEC 61260-1:2014 and IEC 61672-1:2013 as far as they constrain each row of a
(channels, samples)array: the band filters, weightings and detector ballistics applied per channel are the unchanged single-channel designs, and multichannel support adds no normative content of its own. The convention itself follows Bendat & Piersol’s procedure for multiple data records (Section 10.4.2): analyze each record individually first, and treat anything joint as a separate, explicit step.Not covered
Cross-channel questions, such as the delay between two channels or how much of one channel a second one explains, are deliberately left out:
octave_filter,weighting_filterandtime_weightingnever mix or combine rows. See Correlation and delay and Multiple and partial coherence for the cross-channel tools built on Bendat & Piersol’s cross-correlation and multiple-input models.
See also
Section titled “See also”- Correlation and delay: the cross-channel time-domain questions (delay, alignment) the per-channel path deliberately leaves to you.
- Multiple and partial coherence: which of several correlated channels actually drives a response (Bendat & Piersol Ch. 7).
- Block Processing: the streaming counterpart, with one filter state per channel.
- Levels: the per-channel level metrics, and why dB values are combined energetically.
- API reference:
phonometryandfilters.core.
References
Section titled “References”- Bendat, J. S., & Piersol, A. G. (2010). Random data: Analysis and measurement procedures (4th ed.). Wiley. https://doi.org/10.1002/9781118032428Section 10.4.2 (the procedure for analyzing multiple data records: individual per-record analysis first, joint cross-record analysis as a separate, deliberate step) and Chapter 7 (the multiple-input/output models that joint step leads to). ISBN 978-0-470-24877-5.
- International Electrotechnical Commission. (2013). Electroacoustics — Sound level meters — Part 1: Specifications (IEC 61672-1:2013). The weighting and time-integration semantics applied per channel, exactly as the single-channel standard prescribes.
- International Electrotechnical Commission. (2014). Electroacoustics — Octave-band and fractional-octave-band filters — Part 1: Specifications (IEC 61260-1:2014). The band definitions each channel is filtered with; the multichannel path batches them unchanged. Multichannel support adds no normative content of its own: each channel is filtered exactly as the single-channel standard prescribes, and the vectorization only batches the computation across the channel axis.