feat: STOI/ESTOI objective intelligibility and AES17 dynamic range / idle channel noise#230
Conversation
…el noise Add the two correlation-based objective speech-intelligibility measures in phonometry.hearing.objective_intelligibility: STOI (Taal, Hendriks, Heusdens & Jensen 2011) and ESTOI (Jensen & Taal 2016). They share a 10 kHz, 256-sample Hann, 512-point, 15-band one-third-octave, 384 ms segment front end; STOI averages the clipped per-band envelope correlation, ESTOI the row- and column-normalised spectral correlation that tracks modulated maskers. The stoi() entry point returns a STOIResult with the index, the intermediate scores and a .plot(). Add the AES17-2015 noise measurements to electroacoustics.distortion: dynamic_range (6.4.1) and idle_channel_noise (6.4.2), both reusing the standard notch and the ITU-R BS.468-4 curve through the CCIR-RMS weighting (the 468 curve with the standard -5.63 dB offset, unity at 2 kHz).
STOI/ESTOI: degenerate cases (identical -> 1, uncorrelated -> low), SNR monotonicity, level invariance, input validation and an external cross-check against pystoi (added as a test-only dependency; the library reimplements from the papers and never imports it at runtime, and the two agree to under 1e-6). AES17: closed-form dBFS oracles for idle channel noise (a 1 kHz tone reads its level minus the 5.63 dB CCIR-RMS offset) and dynamic range (full-scale sine over a known residual), plus level scaling and monotonicity with noise. Register five conformance checks and refresh docs/CONFORMANCE.md.
…gure Add the Objective Intelligibility (STOI & ESTOI) guide across the GitHub docs and the EN/ES site, wire it into the Speech section, sidebar and indexes, and document the AES17 dynamic range and idle channel noise in the electroacoustics guides. Add the STOI-vs-ESTOI concept figure (four language/theme variants) showing that ESTOI credits the speech glimpsed in a modulated masker's gaps while STOI barely separates the two maskers. Refresh the generated API reference, the curated API table and the CHANGELOG.
📝 WalkthroughWalkthroughThe PR adds STOI/ESTOI speech-intelligibility metrics and AES17 dynamic-range and idle-channel-noise measurements, exposes their APIs, adds validation and conformance coverage, and updates guides, API references, navigation, generated documentation, and changelog content. ChangesMeasurement metrics
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant Caller
participant stoi
participant resample_poly
participant STOIResult
Caller->>stoi: clean, degraded, fs, extended
stoi->>resample_poly: resample inputs to 10 kHz
resample_poly-->>stoi: resampled signals
stoi->>STOIResult: create score and intermediate arrays
STOIResult-->>Caller: return value and plotting interface
Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Code Review
This pull request introduces objective speech intelligibility measures (STOI and ESTOI) and AES17-2015 noise measurements (dynamic range and idle channel noise) along with their corresponding documentation, conformance reports, and tests. Feedback on the implementation suggests adding validation checks in the electroacoustics module to handle empty input signals and to ensure the fundamental frequency remains below the Nyquist frequency. Additionally, the short-time framing and spectrogram calculation logic in the objective intelligibility module should be adjusted to correctly include the final valid frame of the signal.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| n = x.size | ||
| spec = np.fft.rfft(x) |
There was a problem hiding this comment.
If the input signal x is empty, performing reduction operations (such as computing the mean square) will result in errors. Always validate that input arrays are not empty before performing reduction operations and raise a clear, descriptive ValueError.
| n = x.size | |
| spec = np.fft.rfft(x) | |
| n = x.size | |
| if n == 0: | |
| raise ValueError("Input signal x cannot be empty.") | |
| spec = np.fft.rfft(x) |
References
- Always validate that input arrays are not empty before performing reduction operations (such as np.min or np.max) to prevent cryptic system errors and instead raise a clear, descriptive ValueError.
There was a problem hiding this comment.
Declined: _ccir_rms_weighted_rms is a private helper, and both public callers run _validate_signal (which requires at least 64 samples) first, so it can never receive an empty array.
| else: | ||
| f0 = _positive(fundamental, "fundamental") | ||
| residual = _notched_residual(sig, fs_v, f0, float(notch_q)) |
There was a problem hiding this comment.
If the fundamental frequency f0 is greater than or equal to the Nyquist frequency (fs / 2), the underlying notch filter design will fail and raise a less descriptive ValueError from SciPy. Adding a defensive check here ensures a clear and helpful error message is presented to the user.
| else: | |
| f0 = _positive(fundamental, "fundamental") | |
| residual = _notched_residual(sig, fs_v, f0, float(notch_q)) | |
| else: | |
| f0 = _positive(fundamental, "fundamental") | |
| if f0 >= fs_v / 2.0: | |
| raise ValueError("Fundamental frequency must be less than the Nyquist frequency (fs / 2).") | |
| residual = _notched_residual(sig, fs_v, f0, float(notch_q)) |
There was a problem hiding this comment.
Declined: dynamic_range calls _notched_residual, which already raises a descriptive error when the fundamental is at or above the Nyquist frequency, before SciPy is reached.
| def _frame_signal(sig: NDArray[np.float64], window: NDArray[np.float64], | ||
| hop: int) -> NDArray[np.float64]: | ||
| """Stack of ``window``-tapered, ``hop``-spaced frames of length ``_FRAME``.""" | ||
| starts = range(0, sig.size - _FRAME, hop) |
There was a problem hiding this comment.
The range range(0, sig.size - _FRAME, hop) excludes the very last frame of the signal when the signal length is exactly a multiple of hop plus _FRAME (since the stop value of range is exclusive). Changing the stop value to sig.size - _FRAME + 1 ensures that the final valid frame is included and processed.
| starts = range(0, sig.size - _FRAME, hop) | |
| starts = range(0, sig.size - _FRAME + 1, hop) |
There was a problem hiding this comment.
Declined with evidence: pystoi and the authors' MATLAB use the exclusive frame range without +1. Adding it would create an extra frame whenever (n - frame) is divisible by the hop, changing the frame count and breaking the 1e-16 parity with the reference. The oracle must not move.
| summed over each band's bins. | ||
| """ | ||
| hop = _FRAME // 2 | ||
| starts = range(0, sig.size - _FRAME, hop) |
There was a problem hiding this comment.
Similar to _frame_signal, the range range(0, sig.size - _FRAME, hop) excludes the final valid frame when the signal length is exactly a multiple of hop plus _FRAME. Changing the stop value to sig.size - _FRAME + 1 ensures the last frame is included in the spectrogram computation.
| starts = range(0, sig.size - _FRAME, hop) | |
| starts = range(0, sig.size - _FRAME + 1, hop) |
There was a problem hiding this comment.
Declined for the same reason as the sibling thread: the exclusive range matches the reference implementation; +1 would alter the frame count and break parity.
| # there is nothing to segment. Checking the frame count here keeps the | ||
| # friendly message (a bare matmul on an empty spectrogram would raise a | ||
| # cryptic shape error instead). | ||
| n_frames = len(range(0, x.size - _FRAME, _FRAME // 2)) |
There was a problem hiding this comment.
There was a problem hiding this comment.
Declined for the same reason: the frame range is intentionally exclusive to match pystoi and the authors' MATLAB; verified parity stays at 1e-16.
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #230 +/- ##
==========================================
+ Coverage 96.10% 96.11% +0.01%
==========================================
Files 134 135 +1
Lines 17831 18041 +210
==========================================
+ Hits 17137 17341 +204
- Misses 694 700 +6 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/phonometry/_plot/hearing.py`:
- Line 89: Update the y-axis limits in the hearing plot to accommodate
correlation values across the full [-1, 1] range, while preserving the existing
[0, 1] limits for ratio-based plots such as plot_sti and plot_sii.
- Around line 79-81: Review the axis setup around the inline ax.set_xticks and
ax.set_xticklabels calls, and reuse _band_axis only if its abbreviated 1k/2k
labels are acceptable. Otherwise retain the explicit frequency labels so
one-third-octave values such as 1000 and 2000 Hz remain visible.
In `@src/phonometry/hearing/objective_intelligibility.py`:
- Around line 149-153: Vectorize frame extraction and spectral processing in
_frame_signal and _band_spectrogram: use sliding_window_view or equivalent
batched array operations to construct windowed frames, then call np.fft.rfft
once on the 2-D frame array with n=_NFFT and axis=1 instead of iterating per
frame. Preserve the existing frame spacing, windowing, output shape, and
handling of recordings that do not produce complete frames.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro
Run ID: a60a8292-6192-439a-8d41-410f81cfca27
⛔ Files ignored due to path filters (5)
.github/images/stoi_intelligibility.svgis excluded by!**/*.svg.github/images/stoi_intelligibility_dark.svgis excluded by!**/*.svg.github/images/stoi_intelligibility_es.svgis excluded by!**/*.svg.github/images/stoi_intelligibility_es_dark.svgis excluded by!**/*.svgsite/src/generated/api-sidebar.mjsis excluded by!**/generated/**
📒 Files selected for processing (31)
CHANGELOG.mddocs/CONFORMANCE.mddocs/README.mddocs/api-reference.mddocs/electroacoustics.mddocs/objective-intelligibility.mdllms-full.txtllms.txtrequirements-dev.txtscripts/api_taxonomy.pyscripts/conformance_report.pyscripts/generate_graphs.pyscripts/generate_llms.pysite/astro.config.mjssite/src/content/docs/es/guides/electroacoustics.mdxsite/src/content/docs/es/guides/objective-intelligibility.mdxsite/src/content/docs/es/guides/sections/speech.mdsite/src/content/docs/guides/electroacoustics.mdxsite/src/content/docs/guides/objective-intelligibility.mdxsite/src/content/docs/guides/sections/speech.mdsite/src/content/docs/reference/api/electroacoustics/distortion.mdsite/src/content/docs/reference/api/index.mdsite/src/content/docs/reference/api/speech/objective-intelligibility.mdsrc/phonometry/__init__.pysrc/phonometry/_plot/hearing.pysrc/phonometry/electroacoustics/__init__.pysrc/phonometry/electroacoustics/distortion.pysrc/phonometry/hearing/__init__.pysrc/phonometry/hearing/objective_intelligibility.pytests/electroacoustics/test_distortion.pytests/hearing/test_objective_intelligibility.py
Numerical conformance report✅ 324/324 conformance checks pass across 40 domains and 198 standards - filters class 1 - weightings within IEC 61672-1 class 1. Each row pins a standard clause to its expected normative value and the value the library computes. Every section below is collapsible and stays collapsed while all of its rows pass; a section with any failing row opens automatically. ✅ Numerical validation - filters & weightings: class showcase (IEC 61260-1 · IEC 61672-1 · ISO 7196)IEC 61260-1:2014 class per filter architecture (order 6, one-third-octave, 100 Hz-10 kHz, fs = 48 kHz). For each architecture the table shows, at its binding band, the measured relative attenuation and the class-1 limit it must clear, so the number and the range it must sit in are both visible. A positive margin means the acceptance limits are met with that much room.
Only Butterworth (the library default) and Chebyshev-II are class-compliant architectures. Chebyshev-I and elliptic trade the mask for passband ripple, and Bessel for a maximally-flat group delay (soft rolloff); they cannot satisfy the IEC 61260-1 Class 1/2 attenuation mask by construction, so they are labelled By design - this is expected, not a failure or regression. Frequency-weighting conformance (A/C: IEC 61672-1 Table 3; G: ISO 7196 A.3). The max deviation from nominal is informational (it falls at a frequency extreme where the tolerance is widest and asymmetric); compliance is judged at the binding frequency - the one with the least headroom - where the deviation, the applicable tolerance band and the headroom are shown together.
✅ Filters & weightings: 100% (7/7)
✅ Levels & dosimetry: 100% (6/6)
✅ Room acoustics: 100% (12/12)
✅ Psychoacoustics: 100% (12/12)
✅ Speech transmission (IEC 60268-16): 100% (9/9)
✅ Intensity & sound power: 100% (4/4)
✅ Room & building acoustics: 100% (50/50)
✅ Building prediction & uncertainty: 100% (15/15)
✅ Outdoor propagation & occupational exposure: 100% (10/10)
✅ Materials: absorption, airflow & impedance: 100% (6/6)
✅ Scattering & diffusion (ISO 17497): 100% (5/5)
✅ In-situ road absorption (ISO 13472): 100% (3/3)
✅ Precision sound power (ISO 3745 / 9614-3): 100% (4/4)
✅ Human vibration (ISO 8041 / 2631 / 5349): 100% (15/15)
✅ Speech intelligibility (ANSI S3.5-1997): 100% (7/7)
✅ Objective intelligibility (STOI / ESTOI): 100% (3/3)
✅ Impulsive-sound prominence (NT ACOU 112): 100% (2/2)
✅ Room noise (ANSI S12.2-2019): 100% (3/3)
✅ Hearing threshold (ISO 7029 / ISO 389-7): 100% (3/3)
✅ Measurement uncertainty (GUM / Supplement 1): 100% (7/7)
✅ Noise-induced hearing loss (ISO 1999): 100% (3/3)
✅ Multiple-shock whole-body vibration (ISO 2631-5): 100% (6/6)
✅ Sound absorption in enclosed spaces (EN 12354-6): 100% (2/2)
✅ Prominent discrete tones (ECMA-418-1): 100% (2/2)
✅ Tonal audibility (ISO/PAS 20065): 100% (11/11)
✅ Psychoacoustic annoyance & fluctuation strength (Fastl & Zwicker): 100% (3/3)
✅ Electroacoustics: distortion & frequency response: 100% (14/14)
✅ Calibrated spectral analysis (Bendat & Piersol): 100% (6/6)
✅ Correlation, time delay and envelope (B&P / Knapp & Carter): 100% (7/7)
✅ Underwater acoustics (ISO 18405/17208/18406): 100% (6/6)
✅ Underwater sound propagation (transmission loss): 100% (15/15)
✅ Underwater numerical propagation (modes / rays / PE): 100% (4/4)
✅ Aircraft noise (ICAO Annex 16 / IEC 61265): 100% (14/14)
✅ Rotorcraft noise (ECAC Doc 32 / NORAH2): 100% (12/12)
✅ Wind-turbine noise (IEC 61400-11): 100% (3/3)
✅ Porous & multilayer absorbers (Mechel / Bies / Cox & D'Antonio): 100% (10/10)
✅ Program loudness (ITU-R BS.1770 / EBU R 128): 100% (8/8)
✅ 2D FDTD wave simulation (Attenborough & Van Renterghem 2021, Ch. 4): 100% (2/2)
✅ Swept-sine distortion & phase utilities (Farina / Novak): 100% (7/7)
✅ Spherical ground & barriers (Attenborough / Salomons / Bies): 100% (6/6)
Tests & coverage — 23328 tests, 0 failures (✅ all green)
Conformance harness: |
- Extract the AES17 notch-Q validation into a shared _validate_notch_q helper, removing the duplicated range literal across the distortion functions (Sonar S1192). - Move the input construction out of the pytest.raises blocks in the STOI input-validation test so each block exercises a single call (Sonar S5778). - Plot the STOI/ESTOI intermediate correlations on a [-1, 1] axis (they are cosine-similarity quantities, not [0, 1] ratios) so anti-correlated bands are not clipped. - Vectorise the STOI frame extraction and spectrogram (one strided gather and a single batched rfft), numerically identical to the per-frame loop; the pystoi cross-check is unchanged at ~1e-16. The framing keeps the reference range(0, n - frame, hop) (the trailing partial frame is dropped, matching the authors' MATLAB), so the STOI/ESTOI oracle does not move.
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/phonometry/electroacoustics/distortion.py (1)
1027-1031: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
dynamic_range()should not auto-detect whenfundamentalis omitted. Insrc/phonometry/electroacoustics/distortion.py:1027-1031, the docstring saysNoneuses the AES17 997 Hz tone, but this branch picks the largest FFT peak instead. Omitting the argument can notch the wrong component and skew the reported dynamic range. Either default to 997 Hz here or update the public contract and add coverage for the auto-detect path.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/phonometry/electroacoustics/distortion.py` around lines 1027 - 1031, The fundamental=None branch in dynamic_range() violates the documented AES17 behavior by auto-detecting an FFT peak. Make omitted fundamental use the documented 997 Hz tone, while preserving the explicit-fundamental path and existing validation.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@src/phonometry/electroacoustics/distortion.py`:
- Around line 1027-1031: The fundamental=None branch in dynamic_range() violates
the documented AES17 behavior by auto-detecting an FFT peak. Make omitted
fundamental use the documented 997 Hz tone, while preserving the
explicit-fundamental path and existing validation.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro
Run ID: 4f4ce5a7-2cda-4b29-871c-b9083cb4dbfe
📒 Files selected for processing (4)
src/phonometry/_plot/hearing.pysrc/phonometry/electroacoustics/distortion.pysrc/phonometry/hearing/objective_intelligibility.pytests/hearing/test_objective_intelligibility.py
|



Summary
Objective speech-intelligibility prediction for the hearing domain and the remaining AES17 amplifier metrics for electroacoustics, clean-room from Taal et al. (2010, 2011) and Jensen and Taal (2016), and AES17-2015.
stoi(clean, degraded, fs, *, extended=False): the short-time objective intelligibility measure and its extended variant, sharing one front end (resample to 10 kHz, MATLAB Hann framing, 40 dB silent-frame removal, 15 one-third-octave bands from 150 Hz, 384 ms segments). STOI is the clipped per-band envelope correlation; ESTOI is the row- then column-normalised spectral correlation that credits the glimpsing benefit under modulated maskers. Frozen result with per-segment and per-band scores and a.plot().dynamic_rangeandidle_channel_noise(AES17 6.4.1-6.4.2): reuse the existing standard notch and the ITU-R 468 weighting through a CCIR-RMS helper.Validation
The implementation matches the reference pystoi (installed as a test-only dependency) to about 1e-16 for STOI and 1e-15 for ESTOI across SNRs of 20, 10, 0 and -10 dB, reconfirmed independently in review. Degenerate cases are exact (identical signals give 1.0, uncorrelated gives below 0.3), scores are monotonic with SNR and level-invariant, and near-silent or single-tone inputs stay finite. The AES17 metrics match their closed forms (idle noise of a 1 kHz -20 dBFS tone is -25.63 dBFS from the CCIR-RMS offset; dynamic range over a -40 dBFS residual is 40.4 dB). Five new conformance checks (324 total, byte-exact report).
Checks
ruff, mypy, bandit, full pytest (3824 passed, no new warnings), conformance byte-exact, figures within tolerance (852, one new in four variants), api-docs with no drift, curated API table 725 names, site i18n parity and full build clean. Adversarially reviewed; the worthwhile findings applied (friendly short-signal error, tightened contrast tolerance).
Summary by CodeRabbit