Advanced psychoacoustics (ISO 532-2/3 Moore-Glasberg, ECMA-418-2 Sottek loudness/tonality/roughness) + numerical conformance report (#87)
* feat: ECMA-418-2 Sottek Hearing Model loudness and auditory front-end
Implement the ECMA-418-2:2025 (4th ed.) psychoacoustic loudness metric and
its shared auditory front-end (Sottek Hearing Model):
- Clause 5 front-end: outer/middle-ear cascade (5.1.3, Table 1), 53-band
gammatone-like auditory filter bank (5.1.4), band-dependent segmentation
(5.1.5, Table 4), rectification/RMS (5.1.6-5.1.7), compressive nonlinearity
(5.1.8, Formula 23, Table 2) and threshold in quiet (5.1.9, Table 3).
- Clause 6.2.2-6.2.7 ACF-based tonal/noise specific loudness (reusable by the
later tonality/roughness metrics).
- Clause 8 assembly: tonal/noise power average, average specific loudness,
time-dependent and single representative loudness value.
The front-end helpers are factored for reuse. Calibration verified: a 1 kHz
40 dB SPL sinusoid yields 0.996 sone_HMS (target 1.0). Adds a frozen
EcmaLoudness result with lazy .plot(), exports, and 12 tests.
Claude-Session: https://claude.ai/code/session_013kkVt3nxi9svp1an28uHxf
* feat: ECMA-418-2 tonality (Sottek Hearing Model)
Implements the psychoacoustic tonality metric of ECMA-418-2:2025
(Clause 6.2.8-6.2.11) on top of the committed Sottek front-end and
ACF tonal/noise decomposition.
- tonality_ecma() -> EcmaTonality with the single value T (Formula 63),
average specific tonality T'(z) (Formula 53), tonal frequencies
f_ton,z(z) (Formula 55), time-dependent tonality T(l) (Formula 61)
and its frequency f_ton(l) (Formula 62); optional user band [f_low,f_high].
- Full Clause 6.2.3 band averaging with cross-block-size-group ACF
recomputation (loudness keeps its simplified, valid-for-loudness path;
boundary marker added there). Loudness 0.996 sone guard unchanged.
- _tonal_estimate now also returns the DFT-peak tonal frequency, with the
argmax restricted to the lower DFT half to avoid Nyquist-mirror aliasing.
- .plot() via _plotting.plot_ecma_tonality; exports in __init__.
- 13 conformance tests: 1 kHz/40 dB -> 1 tu_HMS calibration, pure tone
>> noise, f_ton tracks the tone, silence -> 0, band restriction.
Claude-Session: https://claude.ai/code/session_013kkVt3nxi9svp1an28uHxf
* feat: ECMA-418-2 roughness (Sottek Hearing Model)
Implement the ECMA-418-2:2025 (4th ed.) psychoacoustic roughness metric
(Clause 7), the third Sottek Hearing Model output and new library
functionality. Reuses the Clause 5 auditory front-end from loudness_ecma
(ear filter, 53-band gammatone bank, specific basis loudness) and adds the
roughness-specific chain: start-only zero-padding and fixed 16384/4096
segmentation (5.1.2.2, 5.1.5.2, 7.1.1), the Hilbert envelope with x32
downsampling to 1500 Hz (7.1.2), the scaled envelope power spectrum
(7.1.3), two-step noise reduction (7.1.4), the four-stage spectral
weighting with quadratic-fit modulation-rate refinement, bias correction,
high/low modulation-rate weighting and fundamental-rate estimation
(7.1.5), interpolation to 50 Hz with the distribution-dependent nonlinear
transform, tabulated calibration c_R and asymmetric smoothing (7.1.7), and
the R'(z) / R(l50) / 90th-percentile aggregation (7.1.8-7.1.10).
Uses the standard's tabulated c_R = 0.0180685 (Formula 104), not
reverse-fit: the 1 kHz / 70 Hz / m=1 / 60 dB calibration signal yields
R = 1.0735 asper (near-but-not-exactly 1). OCR-ambiguous formulae
(66, 71, 78-81) cross-checked against the SQAT MATLAB reference; the
optional entropy weighting (7.1.6, needs RPM telemetry) is documented but
not implemented.
Adds roughness_ecma()/EcmaRoughness, a two-panel .plot(), exports, a
10-test TDD suite and plan/notes-ecma418-2-roughness.md. Full suite
752 passed / 12 skipped; loudness (0.996) and tonality (0.99993) oracles
unchanged (both modules untouched).
Claude-Session: https://claude.ai/code/session_013kkVt3nxi9svp1an28uHxf
* test: pin ECMA-418-2 roughness calibration value and document clean-room variance
Claude-Session: https://claude.ai/code/session_013kkVt3nxi9svp1an28uHxf
* feat: ISO 532-2 Moore-Glasberg loudness
Add the stationary Moore-Glasberg loudness model of ISO 532-2:2017 as a
third loudness method alongside the Zwicker (ISO 532-1) and Sottek
(ECMA-418-2) implementations. The full Clause 7 chain is implemented:
fixed outer/middle-ear transfer (Table 1), the level-dependent roex
auditory filter bank and excitation pattern on the ERB-number scale
(Formulae 1-6), the compressive specific-loudness transform (Formulae
7-9, Tables 2-4, C=0.0617 sone/Cam), binaural inhibition (Formulae
10-13) and integration to total loudness with the phon mapping of
Table 5.
Public API: loudness_moore_glasberg_from_spectrum (exact 5.2/5.4),
loudness_moore_glasberg_from_third_octave (29 bands, 5.5) and
loudness_moore_glasberg (signal wrapper), returning a frozen
MooreGlasbergLoudness with .plot() over the ERB-number scale.
Validated against the Annex B reference signals (tones, white/pink
noise, multi-tone complexes, tone-in-noise) to ~1-4%, well inside the
standard's 2.8 phon expanded uncertainty; the definitional anchor
(1 kHz/40 dB/free/binaural = 1.000 sone) holds end-to-end from the
tabulated constant. 50 new tests; full suite 802 passed / 12 skipped.
Claude-Session: https://claude.ai/code/session_013kkVt3nxi9svp1an28uHxf
* feat: ISO 532-3 Moore-Glasberg-Schlittenlacher time-varying loudness
Add loudness_moore_glasberg_time() implementing the ISO 532-3:2023 method:
a six-FFT multi-resolution running short-term spectrum (clause 7.3) feeding
the ISO 532-2 excitation and specific-loudness model on the 0.25 Cam grid
with the 532-3 constants (C=0.063, Tables 2/4, E_THRQ/E0=2.307), integrated
by asymmetric attack/release smoothing into short-term (clauses 7.6-7.8) and
long-term (clause 7.9) loudness traces, with peak long-term loudness,
percentiles and a lazy STL/LTL .plot().
Validated against the Annex C.1 tone table (1 kHz 10-80 dB, 3 kHz, 4 kHz,
100 Hz and monaural earphone) to within 0.4 phon, well inside the 2.8 phon
expanded uncertainty; the 1 kHz/40 dB anchor is exact at 1.000 sone. The
ISO 532-2 and ECMA-418-2 oracles are unchanged (831 passed / 12 skipped).
Claude-Session: https://claude.ai/code/session_013kkVt3nxi9svp1an28uHxf
* fix: ISO 532-3 per-ear LTL and Formula-14 kernel width (dichotic path)
Two transcription errors in the ISO 532-3:2023 dichotic (per-ear) binaural
path of the time-varying Moore-Glasberg-Schlittenlacher module:
1. Clause 7.9: the long-term loudness must be computed per ear by the
attack/release averager (Formulae 18-21) and then summed. The code ran a
single AGC on the binaural sum S'_L + S'_R; because the attack/release
branch is nonlinear, AGC(S'_L) + AGC(S'_R) != AGC(S'_L + S'_R) for dichotic
input. Keep two LTL states and sum the two long-term loudnesses. For
diotic/monaural the averager is linear in the (scaled) input so the result
is bit-for-bit identical to the old sum-then-AGC.
2. Formula (14)/(15): the smoothing weight is exp(-(B*Di)^2) with B = 0.08 and
Di in Cam. The code evaluated exp(-((0.08 * Di/0.1)^2)) = exp(-(0.8*Di)^2),
a Gaussian ~10x too narrow, and truncated the taps at +/-1.75 Cam. Remove
the spurious /0.1 and widen the kernel to the standard's +/-18 Cam (145 taps
on the 0.25-Cam grid). For diotic/monaural the L/R excitation ratio is 1
regardless of the kernel, so all oracles are unaffected.
No current validation signal exercises the dichotic path (Annex C signals are
diotic/monaural), so the 532-3 tone oracle and every other oracle remain
byte-identical; added focused dichotic tests plus a byte-identical anchor pin.
Claude-Session: https://claude.ai/code/session_013kkVt3nxi9svp1an28uHxf
* feat: numerical conformance report in CI PR comment
Reframe the PR comment into a persistent numerical conformance report:
each implemented standard -> normative expected value/range -> computed
result -> pass/fail with deviation, grouped by domain.
- scripts/conformance_report.py: registry of 21 checks across 17 standards
and 6 domains (filters/weightings, levels/dosimetry, psychoacoustics,
speech, intensity/power, room/building). Emits a headline summary, a
"Numerical validation - filters & weightings" section (per-architecture
IEC 61260-1 class margins + A/C/G weighting deviation vs the normative
curve) and one conformance table per domain. Deterministic, ~2.7 s.
- tests/reference_data.py: single source of truth for the shared normative
tables (IEC 61672-1 Table 3, ISO 7196 Table 2, ISO 717-1 Annex C R),
imported by both the tests and the report so they cannot drift.
- tests/test_conformance_report.py: smoke test asserting every registered
check passes and the Markdown is well-formed.
- comment_pr.py / workflow: conformance report leads the comment; the
test/coverage table is collapsed below. Retire benchmark_filters.py
(numerical content absorbed into the report).
make check green (740 passed, 12 skipped); ruff, mypy --strict, bandit clean.
Claude-Session: https://claude.ai/code/session_013kkVt3nxi9svp1an28uHxf
* refactor: block-A review minors — docstring notes and tonality band-range
Four Minor findings from the plan-17 block-A psychoacoustics reviews. No
change to any validated numeric output; all five oracles stay byte-identical.
1. ISO 532-2 _source_levels: docstring note that clause 7.4 specifies a roex
weighting for X where the code uses a rectangular +/-ERB_n/2 window —
identical for isolated tones, immaterial for the Annex B broadband cases.
2. ISO 532-2 Formula-10 binaural smoothing kernel: the PDF (clause 8.1) states
"Di is changed in steps of 0,1" over -18..+18 Cam; the code confused the
0.1-Cam index offset with Di-in-Cam (the same /0.1 width bug just fixed in
532-3 Formula-14), making the Gaussian 10x too narrow and truncating to
+/-1.8 Cam. Widen to +/-18 Cam (361 taps) with Di = tap*_I_STEP. Provably
inert: all Annex B cases are diotic (ratio 1) or monaural (unit inhibition).
3. ECMA tonality _band_range: implement the exact Formulae 56-57 edge-midpoint
boundary conditions instead of a centre-frequency threshold; only affects
the optional user-band feature. Added a user-band edge test.
4. ECMA tonality reach: comment that the _CBF-1-band upper-edge term is a dead
defensive guard (N_B>0 only for low bands 0..24, so it never limits reach).
Claude-Session: https://claude.ai/code/session_013kkVt3nxi9svp1an28uHxf
* chore: move conformance design doc out of repo root
* feat: clearer conformance report (value-vs-range) + block-A checks + audit fixes
Table 1 (filters): show, per architecture, the measured relative attenuation
at the binding band vs the class-1 limit it must clear (value + range, not
just a margin). Replace the "none" verdict (read as a failure) with a
"By design (ripple/soft rolloff)" label + footnote; Butterworth (default) and
Chebyshev-II are the only mask-compliant architectures. The measured value and
limit are re-derived with the public class_limits on the same designed SOS; a
smoke-test guard asserts the re-derived class-1 margin matches
verify_filter_class (single source of truth).
Table 2 (weightings): separate the informational max-deviation-from-nominal
(at a frequency extreme with wide, asymmetric tolerance) from the compliance
margin at the binding frequency, where the deviation, the +/- tolerance band
and the headroom are co-located so "value vs range" is unambiguous and in-spec.
Audit: verified all 21 checks (expected value/range vs the standard, real
library call, honest tolerance). One fix - ISO 226 anchored at 60 phon @ 2 kHz
-> 60 dB, a genuine Table B.1 value but coincidentally equal to the phon number
(reads as the trivial 1 kHz identity); re-anchored to the non-coincidental
60 phon @ 100 Hz -> 78.5 dB, sourced from reference_data. No library bug found.
Block-A psychoacoustics (+5, single-source expected in reference_data):
ECMA-418-2 loudness (1 kHz/40 dB -> 1 sone_HMS, c_N), tonality (-> 1 tu_HMS,
c_T), roughness (1 kHz/70 Hz/m=1/60 dB -> clean-room 1.0735 asper with a note
that the standard target is 1.0); ISO 532-2 (1 kHz/40 dB -> 1 sone, C=0.0617);
ISO 532-3 (steady 1 kHz/40 dB -> peak LTL 1 sone). 26/26 checks, 20 standards.
Claude-Session: https://claude.ai/code/session_013kkVt3nxi9svp1an28uHxf
* feat: versioned docs/CONFORMANCE.md with make target, CI staleness check and README link
Persist the numerical conformance report as a committed, versioned file so
every reader sees the library's per-standard validation (expected vs computed)
without running CI.
- docs/CONFORMANCE.md: committed report (26/26 checks, 6 domains, 20 standards)
with an auto-generated "do not hand-edit" header linking the standards docs.
- scripts/conformance_report.py: add a --file-header flag that emits that header
for the committed file (PR-comment body stays header-free); coarsen the
informational delta column to 3 decimals so BLAS/FFT-dependent sub-milli
residuals do not churn the file (status/computed columns keep regressions
visible).
- Makefile: `make conformance` regenerates the file; `make install-hooks`
installs the optional pre-commit hook.
- CI: new fast `conformance` job (runtime deps only) fails a PR if the committed
file is stale, telling the author to run `make conformance` and commit.
- hooks/pre-commit: opt-in convenience hook, scoped to src/scripts/reference
changes; CI is the enforcement.
- README + CONTRIBUTING: link and document the generated report.
Claude-Session: https://claude.ai/code/session_013kkVt3nxi9svp1an28uHxf
* docs: advanced psychoacoustics guide, theory, API (EN)
Document the plan-17 block-A advanced psychoacoustics models: Moore-Glasberg
loudness (ISO 532-2/532-3), Sottek Hearing Model loudness, tonality and
roughness (ECMA-418-2:2025).
- Psychoacoustics guide: new "Advanced loudness & sound-quality models"
section with a model-comparison table, executed snippets, parameter tables,
res.plot() one-liners and figure-code collapsibles (docs + site twin,
code blocks byte-identical).
- Theory: excitation-pattern/specific-loudness chain, Sottek front-end,
ACF tonality and envelope-modulation roughness (GitHub-safe math).
- API reference: rows for the 5 functions (+ 2 MG spectral entry points) and
5 result dataclasses with their .plot().
- README, EN landing (standards 26 -> 29: ISO 532-2, ISO 532-3, ECMA-418-2),
CHANGELOG Unreleased/Added, llms regen.
Claude-Session: https://claude.ai/code/session_013kkVt3nxi9svp1an28uHxf
* docs: advanced-psychoacoustics figures (loudness models, Sottek, tonality/roughness, time-varying)
Claude-Session: https://claude.ai/code/session_013kkVt3nxi9svp1an28uHxf
* docs: gemelas ES de psicoacústica avanzada (Moore-Glasberg, Sottek, tonalidad, aspereza)
Claude-Session: https://claude.ai/code/session_013kkVt3nxi9svp1an28uHxf
* fix: ISO 532-2 signal-input path builds a narrowband spectrum (pure-tone loudness)
The loudness_moore_glasberg(x, fs) wrapper routed the signal through the
one-third-octave method, which smeared a pure tone across a whole 1/3-octave
band and used a coherent-gain window normalisation that over-counted power by
1.76 dB. A calibrated 1 kHz tone at 40 dB SPL therefore returned 1.327 sone
instead of the definitional anchor of 1.000 sone.
ISO 532-2 is spectrum-based and its exact input (clauses 5.2/5.4) is a set of
discrete sinusoidal components; the excitation pattern (Formula 5) is computed
per component, so a tone must enter as a single line. The wrapper now forms the
signal's narrowband (FFT) line spectrum with a power-preserving (Parseval)
window normalisation and feeds it to loudness_moore_glasberg_from_spectrum. A
1 kHz/40 dB tone now yields 1.0001 sone / 40 phon and multi-tone signals match
the exact spectrum path. The validated spectrum and third-octave paths are
byte-identical.
Claude-Session: https://claude.ai/code/session_013kkVt3nxi9svp1an28uHxf
* docs: correct MG signal-path mechanism text and presentation default
Claude-Session: https://claude.ai/code/session_013kkVt3nxi9svp1an28uHxf
* fix: PR 87 review round 1 — float-equality guards, 532-3 FFT window length, tonality short-signal, report/figure fixes
- roughness_ecma: replace three float == 0.0 checks with abs>eps guards
(SonarCloud gate); roughness oracle byte-identical (1.0735).
- loudness_moore_glasberg_time: per-window FFT length (>= window length) so
the 64 ms window is not truncated at 44.1/48 kHz (was ~0.74 dB low in the
20-80 Hz band). 32 kHz anchor byte-identical (n_max 1.0000044713237626).
- tonality_ecma: short signals fall back to averaging all blocks (matches
loudness_ecma), not the final block only; validated anchor unaffected.
- conformance_report: document deliberate 273.0 (speed of sound) vs 273.15
(C1/C2) split matching the library; complete bind_side docstring.
- generate_graphs: roughness annotation uses computed peak fmod; Sottek
metrics use tu_HMS/sone_HMS units; ES translations + 8 figures regenerated.
- hooks/pre-commit: drop set -e so a benign regen failure does not block the
commit (CI staleness check is the gate).
- test_iec_weighting_table3 docstring, Makefile .PHONY completion.
All 5 psychoacoustics oracles byte-identical; make check 868 passed / 12
skipped; ruff + mypy + bandit clean; conformance 26/26.
Claude-Session: https://claude.ai/code/session_013kkVt3nxi9svp1an28uHxf
* fix: localize decimal commas in Sottek-unit figure labels (underscore guard)
The `_` guard in `_translate_figure` skipped decimal-comma localization for
any label containing an underscore. The new Sottek/HMS unit tokens
(`sone_HMS`, `tu_HMS`, `sonios_HMS`) carry underscores, so Spanish figures
regressed to dots (`8.0`, `0.42`) instead of commas (`8,0`, `0,42`).
Drop the underscore guard: the decimal-comma regex only rewrites a bare
`digit.digit` not adjacent to more digits/dots, so unit identifiers and
version numbers (5.3.3) stay intact while genuine decimals get commas.
Mathtext is still skipped via the `$` guard. Regenerated the affected ES
figures (sottek_specific_loudness, tonality_roughness_demo).
Claude-Session: https://claude.ai/code/session_013kkVt3nxi9svp1an28uHxf