Skip to content

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.

Stereo analysis: pink noise and logarithmic sweep resolved per channel in one-third-octave bandsStereo analysis: pink noise and logarithmic sweep resolved per channel in one-third-octave bands

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 plt
import numpy as np
from scipy.signal import chirp
from phonometry import filters
# 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 = 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 np
from phonometry import filters
# 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 = filters.octave_filter(stereo, fs, fraction=3)
# spl has shape (2, n_bands): one row per channel
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 preservedArray-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
InputInterpreted asTypical output
(n,) 1D arrayone channelscalar level / (bands,)
(ch, n) 2D arraych channels, n samples each(ch,) levels / (ch, bands)
list of floatsone channel (converted)as 1D
(ch, n) into spectrogrammultichannel 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.

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= on leq, laeq, ln_levels, lc_peak and sel — 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.
Upper panel: five thin grey one-third-octave curves from five positions in one room, together with a thick blue energy average and a dashed red arithmetic average of the decibel values. Above about 400 Hz the five positions agree within a decibel and the two averages coincide; below it, in the modal region, the positions scatter by up to fifteen decibels and the dashed red average runs below the blue one. Lower panel: the difference between them per band, zero above 400 Hz and rising to a worst under-read of 3.8 dB at 40 HzUpper panel: five thin grey one-third-octave curves from five positions in one room, together with a thick blue energy average and a dashed red arithmetic average of the decibel values. Above about 400 Hz the five positions agree within a decibel and the two averages coincide; below it, in the modal region, the positions scatter by up to fifteen decibels and the dashed red average runs below the blue one. Lower panel: the difference between them per band, zero above 400 Hz and rising to a worst under-read of 3.8 dB at 40 Hz

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 dB

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) capture
calibrated = 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.

A four-microphone array capture. On the left, four measurement microphones on tripods at room-survey spacing, labelled P1 to P4 with their capsules at 1.2 m above the floor and each stamped with its own sensitivity in millivolts per pascal; a sound calibrator is drawn coupled onto the P2 capsule with a dashed arrow showing it moved from capsule to capsule. In the middle, one four-channel preamplifier and one interface with a highlighted bar spanning all four inputs reading single sample clock, fs equals 48 kHz, and beside it a crossed-out second interface labelled two interfaces equals two clocks, not one array. On the right, the array x drawn as four stacked rows labelled ch0 equals P1 through ch3 equals P4 with the shape (4, N). A footer repeats the three invariants: one clock, locked gains, recorded row-to-position mapA four-microphone array capture. On the left, four measurement microphones on tripods at room-survey spacing, labelled P1 to P4 with their capsules at 1.2 m above the floor and each stamped with its own sensitivity in millivolts per pascal; a sound calibrator is drawn coupled onto the P2 capsule with a dashed arrow showing it moved from capsule to capsule. In the middle, one four-channel preamplifier and one interface with a highlighted bar spanning all four inputs reading single sample clock, fs equals 48 kHz, and beside it a crossed-out second interface labelled two interfaces equals two clocks, not one array. On the right, the array x drawn as four stacked rows labelled ch0 equals P1 through ch3 equals P4 with the shape (4, N). A footer repeats the three invariants: one clock, locked gains, recorded row-to-position map

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.

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:

ChannelsOne call [ms]Per-channel loop [ms]Ratio
11931730.90
23373521.04
8139417211.23
32564652080.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 np
from phonometry import filters
# 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 = 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 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).
  • Optional numba: the impulse time weighting kernel is JIT-compiled when numba is installed (pip install phonometry[perf]).
  • 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_filter and time_weighting never 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.

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