[READ-ONLY] Mirror of https://github.com/jmrplens/PyOctaveBand. [Python3] Octave-Band and Fractional Octave-Band filter. For signal in time domain. jmrplens.github.io/PyOctaveBand/
acoustics audio filter frequency frequency-analysis frequency-domain octave python3 signal time-domain
0

Configure Feed

Select the types of activity you want to include in your feed.

feat: hearing threshold (ISO 7029:2017 age model, ISO 389-7:2006 reference) (#101)

Add phonometry.hearing: age_threshold (ISO 7029:2017 statistical age distribution — median, spreads, population fractile) and reference_threshold (ISO 389-7:2006 free/diffuse-field audiometric zero) over 125 Hz - 8000 Hz. Coefficients parsed digit-exact from the standard. AgeThresholdResult with .plot(); 11 tests; 3 conformance checks (76/76); figure + diagram (EN/ES, light/dark); repo doc + site guides EN/ES.

authored by

José M. Requena Plens and committed by
GitHub
(Jul 9, 2026, 11:40 AM +0200) acee899b 14bf4561

+972 -2
+1
.github/images/diagram_hearing_threshold.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" width="900" height="600" viewBox="0 0 900 600"><rect width="900" height="600" fill="#ffffff"/><text x="450.0" y="30" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="26" font-weight="600" fill="#1a1a1a" text-anchor="middle">Hearing-threshold model (ISO 7029 age distribution, ISO 389-7 zero)</text><rect x="180.0" y="56" width="540.0" height="62.0" rx="10" fill="#f0f2f5" stroke="#1a1a1a" stroke-width="2"/><text x="450.0" y="84" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="20" fill="#1a1a1a" text-anchor="middle" font-weight="600">Age Y, sex, population fractile Q</text><text x="450.0" y="106" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="15" fill="#666666" text-anchor="middle">audiometric frequencies 125 Hz – 8000 Hz</text><line x1="450.0" y1="118" x2="450.0" y2="143.0" stroke="#1a1a1a" stroke-width="1.8" stroke-linecap="round"/><path d="M 450.0 152.0 L 446.4 143.0 L 453.6 143.0 Z" fill="#1a1a1a" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><rect x="140.0" y="152" width="620.0" height="60.0" rx="10" fill="#f0f2f5" stroke="#1f77b4" stroke-width="2"/><text x="450.0" y="178" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#1a1a1a" text-anchor="middle" font-weight="600">Median deviation from age 18 (ISO 7029, 4.2)</text><text x="450.0" y="199" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="14" fill="#666666" text-anchor="middle">dHmd = a · (Y − 18) ^ b (Table 1, by sex)</text><rect x="140.0" y="244" width="620.0" height="60.0" rx="10" fill="#f0f2f5" stroke="#1a1a1a" stroke-width="2"/><text x="450.0" y="270" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#1a1a1a" text-anchor="middle" font-weight="600">Spread su / sl (ISO 7029, 4.3)</text><text x="450.0" y="291" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="14" fill="#666666" text-anchor="middle">degree-5 polynomials in (Y − 18) (Tables 2–5)</text><rect x="140.0" y="336" width="620.0" height="60.0" rx="10" fill="#f0f2f5" stroke="#1a1a1a" stroke-width="2"/><text x="450.0" y="362" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#1a1a1a" text-anchor="middle" font-weight="600">Fractile threshold (ISO 7029, 4.4)</text><text x="450.0" y="383" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="14" fill="#666666" text-anchor="middle">dHQ = dHmd + z(Q) * s (su if Q &gt;= 0.5, else sl)</text><line x1="450.0" y1="212" x2="450.0" y2="235.0" stroke="#1a1a1a" stroke-width="1.8" stroke-linecap="round"/><path d="M 450.0 244.0 L 446.4 235.0 L 453.6 235.0 Z" fill="#1a1a1a" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><line x1="450.0" y1="304" x2="450.0" y2="327.0" stroke="#1a1a1a" stroke-width="1.8" stroke-linecap="round"/><path d="M 450.0 336.0 L 446.4 327.0 L 453.6 327.0 Z" fill="#1a1a1a" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><line x1="450.0" y1="396" x2="450.0" y2="421.0" stroke="#1a1a1a" stroke-width="1.8" stroke-linecap="round"/><path d="M 450.0 430.0 L 446.4 421.0 L 453.6 421.0 Z" fill="#1a1a1a" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><rect x="140.0" y="430" width="620.0" height="58" rx="10" fill="none" stroke="#1f77b4" stroke-width="2.4"/><text x="450.0" y="456" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="19" fill="#1a1a1a" text-anchor="middle" font-weight="600">Expected hearing threshold level (dB HL)</text><text x="450.0" y="476" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="14" fill="#1f77b4" text-anchor="middle">referenced to the audiometric zero</text><rect x="140.0" y="506" width="620.0" height="52" rx="10" fill="#f0f2f5" stroke="#d62728" stroke-width="2"/><text x="450.0" y="530" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="17" fill="#1a1a1a" text-anchor="middle" font-weight="600">Audiometric zero = ISO 389-7 reference threshold</text><text x="450.0" y="549" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="14" fill="#666666" text-anchor="middle">free-field / diffuse-field (Table 1) — the dB HL / dB SPL zero</text></svg>
+1
.github/images/diagram_hearing_threshold_dark.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" width="900" height="600" viewBox="0 0 900 600"><rect width="900" height="600" fill="#0d1117"/><text x="450.0" y="30" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="26" font-weight="600" fill="#e6e6e6" text-anchor="middle">Hearing-threshold model (ISO 7029 age distribution, ISO 389-7 zero)</text><rect x="180.0" y="56" width="540.0" height="62.0" rx="10" fill="#1c2128" stroke="#e6e6e6" stroke-width="2"/><text x="450.0" y="84" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="20" fill="#e6e6e6" text-anchor="middle" font-weight="600">Age Y, sex, population fractile Q</text><text x="450.0" y="106" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="15" fill="#9a9a9a" text-anchor="middle">audiometric frequencies 125 Hz – 8000 Hz</text><line x1="450.0" y1="118" x2="450.0" y2="143.0" stroke="#e6e6e6" stroke-width="1.8" stroke-linecap="round"/><path d="M 450.0 152.0 L 446.4 143.0 L 453.6 143.0 Z" fill="#e6e6e6" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><rect x="140.0" y="152" width="620.0" height="60.0" rx="10" fill="#1c2128" stroke="#4da3d8" stroke-width="2"/><text x="450.0" y="178" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#e6e6e6" text-anchor="middle" font-weight="600">Median deviation from age 18 (ISO 7029, 4.2)</text><text x="450.0" y="199" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="14" fill="#9a9a9a" text-anchor="middle">dHmd = a · (Y − 18) ^ b (Table 1, by sex)</text><rect x="140.0" y="244" width="620.0" height="60.0" rx="10" fill="#1c2128" stroke="#e6e6e6" stroke-width="2"/><text x="450.0" y="270" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#e6e6e6" text-anchor="middle" font-weight="600">Spread su / sl (ISO 7029, 4.3)</text><text x="450.0" y="291" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="14" fill="#9a9a9a" text-anchor="middle">degree-5 polynomials in (Y − 18) (Tables 2–5)</text><rect x="140.0" y="336" width="620.0" height="60.0" rx="10" fill="#1c2128" stroke="#e6e6e6" stroke-width="2"/><text x="450.0" y="362" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#e6e6e6" text-anchor="middle" font-weight="600">Fractile threshold (ISO 7029, 4.4)</text><text x="450.0" y="383" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="14" fill="#9a9a9a" text-anchor="middle">dHQ = dHmd + z(Q) * s (su if Q &gt;= 0.5, else sl)</text><line x1="450.0" y1="212" x2="450.0" y2="235.0" stroke="#e6e6e6" stroke-width="1.8" stroke-linecap="round"/><path d="M 450.0 244.0 L 446.4 235.0 L 453.6 235.0 Z" fill="#e6e6e6" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><line x1="450.0" y1="304" x2="450.0" y2="327.0" stroke="#e6e6e6" stroke-width="1.8" stroke-linecap="round"/><path d="M 450.0 336.0 L 446.4 327.0 L 453.6 327.0 Z" fill="#e6e6e6" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><line x1="450.0" y1="396" x2="450.0" y2="421.0" stroke="#e6e6e6" stroke-width="1.8" stroke-linecap="round"/><path d="M 450.0 430.0 L 446.4 421.0 L 453.6 421.0 Z" fill="#e6e6e6" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><rect x="140.0" y="430" width="620.0" height="58" rx="10" fill="none" stroke="#4da3d8" stroke-width="2.4"/><text x="450.0" y="456" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="19" fill="#e6e6e6" text-anchor="middle" font-weight="600">Expected hearing threshold level (dB HL)</text><text x="450.0" y="476" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="14" fill="#4da3d8" text-anchor="middle">referenced to the audiometric zero</text><rect x="140.0" y="506" width="620.0" height="52" rx="10" fill="#1c2128" stroke="#e46a6a" stroke-width="2"/><text x="450.0" y="530" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="17" fill="#e6e6e6" text-anchor="middle" font-weight="600">Audiometric zero = ISO 389-7 reference threshold</text><text x="450.0" y="549" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="14" fill="#9a9a9a" text-anchor="middle">free-field / diffuse-field (Table 1) — the dB HL / dB SPL zero</text></svg>
+1
.github/images/diagram_hearing_threshold_es.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" width="900" height="600" viewBox="0 0 900 600"><rect width="900" height="600" fill="#ffffff"/><text x="450.0" y="30" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="26" font-weight="600" fill="#1a1a1a" text-anchor="middle">Modelo del umbral de audición (ISO 7029 por edad, cero ISO 389-7)</text><rect x="180.0" y="56" width="540.0" height="62.0" rx="10" fill="#f0f2f5" stroke="#1a1a1a" stroke-width="2"/><text x="450.0" y="84" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="20" fill="#1a1a1a" text-anchor="middle" font-weight="600">Edad Y, sexo, fractil poblacional Q</text><text x="450.0" y="106" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="15" fill="#666666" text-anchor="middle">frecuencias audiométricas 125 Hz – 8000 Hz</text><line x1="450.0" y1="118" x2="450.0" y2="143.0" stroke="#1a1a1a" stroke-width="1.8" stroke-linecap="round"/><path d="M 450.0 152.0 L 446.4 143.0 L 453.6 143.0 Z" fill="#1a1a1a" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><rect x="140.0" y="152" width="620.0" height="60.0" rx="10" fill="#f0f2f5" stroke="#1f77b4" stroke-width="2"/><text x="450.0" y="178" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#1a1a1a" text-anchor="middle" font-weight="600">Desviación mediana respecto a los 18 años (ISO 7029, 4.2)</text><text x="450.0" y="199" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="14" fill="#666666" text-anchor="middle">dHmd = a · (Y − 18) ^ b (Tabla 1, por sexo)</text><rect x="140.0" y="244" width="620.0" height="60.0" rx="10" fill="#f0f2f5" stroke="#1a1a1a" stroke-width="2"/><text x="450.0" y="270" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#1a1a1a" text-anchor="middle" font-weight="600">Dispersión su / sl (ISO 7029, 4.3)</text><text x="450.0" y="291" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="14" fill="#666666" text-anchor="middle">polinomios de grado 5 en (Y − 18) (Tablas 2–5)</text><rect x="140.0" y="336" width="620.0" height="60.0" rx="10" fill="#f0f2f5" stroke="#1a1a1a" stroke-width="2"/><text x="450.0" y="362" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#1a1a1a" text-anchor="middle" font-weight="600">Umbral del fractil (ISO 7029, 4.4)</text><text x="450.0" y="383" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="14" fill="#666666" text-anchor="middle">dHQ = dHmd + z(Q) * s (su si Q &gt;= 0.5, si no sl)</text><line x1="450.0" y1="212" x2="450.0" y2="235.0" stroke="#1a1a1a" stroke-width="1.8" stroke-linecap="round"/><path d="M 450.0 244.0 L 446.4 235.0 L 453.6 235.0 Z" fill="#1a1a1a" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><line x1="450.0" y1="304" x2="450.0" y2="327.0" stroke="#1a1a1a" stroke-width="1.8" stroke-linecap="round"/><path d="M 450.0 336.0 L 446.4 327.0 L 453.6 327.0 Z" fill="#1a1a1a" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><line x1="450.0" y1="396" x2="450.0" y2="421.0" stroke="#1a1a1a" stroke-width="1.8" stroke-linecap="round"/><path d="M 450.0 430.0 L 446.4 421.0 L 453.6 421.0 Z" fill="#1a1a1a" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><rect x="140.0" y="430" width="620.0" height="58" rx="10" fill="none" stroke="#1f77b4" stroke-width="2.4"/><text x="450.0" y="456" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="19" fill="#1a1a1a" text-anchor="middle" font-weight="600">Nivel del umbral de audición esperado (dB HL)</text><text x="450.0" y="476" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="14" fill="#1f77b4" text-anchor="middle">referido al cero audiométrico</text><rect x="140.0" y="506" width="620.0" height="52" rx="10" fill="#f0f2f5" stroke="#d62728" stroke-width="2"/><text x="450.0" y="530" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="17" fill="#1a1a1a" text-anchor="middle" font-weight="600">Cero audiométrico = umbral de referencia ISO 389-7</text><text x="450.0" y="549" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="14" fill="#666666" text-anchor="middle">campo libre / campo difuso (Tabla 1) — el cero dB HL / dB SPL</text></svg>
+1
.github/images/diagram_hearing_threshold_es_dark.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" width="900" height="600" viewBox="0 0 900 600"><rect width="900" height="600" fill="#0d1117"/><text x="450.0" y="30" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="26" font-weight="600" fill="#e6e6e6" text-anchor="middle">Modelo del umbral de audición (ISO 7029 por edad, cero ISO 389-7)</text><rect x="180.0" y="56" width="540.0" height="62.0" rx="10" fill="#1c2128" stroke="#e6e6e6" stroke-width="2"/><text x="450.0" y="84" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="20" fill="#e6e6e6" text-anchor="middle" font-weight="600">Edad Y, sexo, fractil poblacional Q</text><text x="450.0" y="106" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="15" fill="#9a9a9a" text-anchor="middle">frecuencias audiométricas 125 Hz – 8000 Hz</text><line x1="450.0" y1="118" x2="450.0" y2="143.0" stroke="#e6e6e6" stroke-width="1.8" stroke-linecap="round"/><path d="M 450.0 152.0 L 446.4 143.0 L 453.6 143.0 Z" fill="#e6e6e6" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><rect x="140.0" y="152" width="620.0" height="60.0" rx="10" fill="#1c2128" stroke="#4da3d8" stroke-width="2"/><text x="450.0" y="178" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#e6e6e6" text-anchor="middle" font-weight="600">Desviación mediana respecto a los 18 años (ISO 7029, 4.2)</text><text x="450.0" y="199" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="14" fill="#9a9a9a" text-anchor="middle">dHmd = a · (Y − 18) ^ b (Tabla 1, por sexo)</text><rect x="140.0" y="244" width="620.0" height="60.0" rx="10" fill="#1c2128" stroke="#e6e6e6" stroke-width="2"/><text x="450.0" y="270" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#e6e6e6" text-anchor="middle" font-weight="600">Dispersión su / sl (ISO 7029, 4.3)</text><text x="450.0" y="291" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="14" fill="#9a9a9a" text-anchor="middle">polinomios de grado 5 en (Y − 18) (Tablas 2–5)</text><rect x="140.0" y="336" width="620.0" height="60.0" rx="10" fill="#1c2128" stroke="#e6e6e6" stroke-width="2"/><text x="450.0" y="362" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#e6e6e6" text-anchor="middle" font-weight="600">Umbral del fractil (ISO 7029, 4.4)</text><text x="450.0" y="383" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="14" fill="#9a9a9a" text-anchor="middle">dHQ = dHmd + z(Q) * s (su si Q &gt;= 0.5, si no sl)</text><line x1="450.0" y1="212" x2="450.0" y2="235.0" stroke="#e6e6e6" stroke-width="1.8" stroke-linecap="round"/><path d="M 450.0 244.0 L 446.4 235.0 L 453.6 235.0 Z" fill="#e6e6e6" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><line x1="450.0" y1="304" x2="450.0" y2="327.0" stroke="#e6e6e6" stroke-width="1.8" stroke-linecap="round"/><path d="M 450.0 336.0 L 446.4 327.0 L 453.6 327.0 Z" fill="#e6e6e6" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><line x1="450.0" y1="396" x2="450.0" y2="421.0" stroke="#e6e6e6" stroke-width="1.8" stroke-linecap="round"/><path d="M 450.0 430.0 L 446.4 421.0 L 453.6 421.0 Z" fill="#e6e6e6" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><rect x="140.0" y="430" width="620.0" height="58" rx="10" fill="none" stroke="#4da3d8" stroke-width="2.4"/><text x="450.0" y="456" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="19" fill="#e6e6e6" text-anchor="middle" font-weight="600">Nivel del umbral de audición esperado (dB HL)</text><text x="450.0" y="476" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="14" fill="#4da3d8" text-anchor="middle">referido al cero audiométrico</text><rect x="140.0" y="506" width="620.0" height="52" rx="10" fill="#1c2128" stroke="#e46a6a" stroke-width="2"/><text x="450.0" y="530" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="17" fill="#e6e6e6" text-anchor="middle" font-weight="600">Cero audiométrico = umbral de referencia ISO 389-7</text><text x="450.0" y="549" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="14" fill="#9a9a9a" text-anchor="middle">campo libre / campo difuso (Tabla 1) — el cero dB HL / dB SPL</text></svg>
.github/images/hearing_threshold.png

This is a binary file and will not be displayed.

.github/images/hearing_threshold_dark.png

This is a binary file and will not be displayed.

.github/images/hearing_threshold_es.png

This is a binary file and will not be displayed.

.github/images/hearing_threshold_es_dark.png

This is a binary file and will not be displayed.

+12 -1
docs/CONFORMANCE.md
··· 15 15 16 16 ## Numerical conformance report 17 17 18 - &#9989; **73/73 conformance checks pass** across 15 domains and 47 standards - filters class 1 - weightings within IEC 61672-1 class 1. 18 + &#9989; **76/76 conformance checks pass** across 16 domains and 49 standards - filters class 1 - weightings within IEC 61672-1 class 1. 19 19 20 20 ### Numerical validation - filters &amp; weightings 21 21 ··· 233 233 234 234 </details> 235 235 236 + <details> 237 + <summary>&#9989; <b>Hearing threshold (ISO 7029 / ISO 389-7)</b> — 100% (3/3)</summary> 238 + 239 + | Standard | Quantity | Expected (norm) | Computed | &#916; | Status | 240 + |:---|:---|:---|:---|:---|:---:| 241 + | ISO 7029:2017 Table 1 | Median threshold, male age 60 at 4 kHz | 20.209 dB (+/-0.001 dB) | 20.208 dB | 0 dB | &#9989; | 242 + | ISO 7029:2017 Table 2 | Upper spread su, male age 60 at 1 kHz | 10.153 dB (+/-0.001 dB) | 10.153 dB | 0 dB | &#9989; | 243 + | ISO 389-7:2006 Table 1 | Free-field reference threshold at 1 kHz | 2.4 dB (+/-0 dB) | 2.4 dB | 0 dB | &#9989; | 244 + 245 + </details> 246 +
+1
docs/README.md
··· 13 13 - [Psychoacoustics and Speech Intelligibility](psychoacoustics.md) — Zwicker loudness, sharpness, STI/STIPA 14 14 - [Speech Intelligibility Index](speech-intelligibility.md) — the ANSI S3.5-1997 one-third-octave-band SII: band-importance weighting (Table 3), self-speech and upward spread of masking, band audibility, and the index in noise and hearing loss 15 15 - [Room-noise criteria](room-noise.md) — the ANSI/ASA S12.2-2019 room-noise ratings: the NC tangency method (Table 1) and the RC Mark II rating with its rumble/hiss/neutral spectral tag (Annex D) 16 + - [Hearing threshold](hearing-threshold.md) — the age-related hearing threshold distribution (ISO 7029:2017) and the free-field/diffuse-field reference threshold of hearing (ISO 389-7:2006) 16 17 - [Sound Intensity (p-p)](intensity.md) — two-microphone intensity and field indicators 17 18 - [Room and Building Acoustics](room-acoustics.md) — impulse-response acquisition, reverberation and room parameters, open-plan speech metrics, field airborne/impact/façade sound insulation (ISO 16283-1/2/3), laboratory characterisation (ISO 10140), flanking-transmission prediction (EN 12354-1/2), measurement uncertainty (ISO 12999-1), sound absorption (ISO 354) 18 19 - [Outdoor Sound Propagation](outdoor-propagation.md) — atmospheric absorption α(f) (ISO 9613-1) and the ISO 9613-2 general method: divergence, atmospheric absorption, ground effect and barrier screening (occupational exposure ISO 9612 lives in [Levels](levels.md))
+109
docs/hearing-threshold.md
··· 1 + ← [Documentation index](README.md) 2 + 3 + # Hearing threshold (age and reference zero) 4 + 5 + Two standards describe where the hearing threshold sits. **ISO 7029:2017** 6 + gives the **statistical distribution of the hearing threshold with age** for an 7 + otologically normal population — the slow, high-frequency-first loss known as 8 + presbycusis. **ISO 389-7:2006** fixes the **reference threshold of hearing**, 9 + the audiometric zero (0 dB HL) expressed as a sound pressure level under 10 + free-field and diffuse-field listening. Both are defined over the audiometric 11 + frequencies from 125 Hz to 8000 Hz. 12 + 13 + <picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/diagram_hearing_threshold_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/diagram_hearing_threshold.svg" alt="The hearing-threshold model: age, sex and a population fractile feed the ISO 7029 chain — the median deviation from age 18 (a times (age minus 18) to the power b, from Table 1 by sex), the upper and lower spreads su and sl (degree-5 polynomials in age minus 18, Tables 2 to 5), and the fractile threshold (median plus the standard-normal quantile z of the fractile times the spread), giving the expected hearing threshold level in dB HL, which is referenced to the audiometric zero, the ISO 389-7 free-field or diffuse-field reference threshold" width="82%"></picture> 14 + 15 + ## 1. Age-related threshold (ISO 7029) 16 + 17 + For a person older than 18, the **median** hearing threshold deviation from the 18 + value at age 18 grows as a power law of age (ISO 7029 clause 4.2, Table 1): 19 + 20 + $$ 21 + \Delta H_{md} = a\,(Y - 18)^{b}, 22 + $$ 23 + 24 + with coefficients $a$, $b$ per frequency and sex. The spread around the median 25 + is modelled by two half-Gaussians whose standard deviations $s_u$ (worse than 26 + the median) and $s_l$ (better) are fifth-degree polynomials in $(Y - 18)$ 27 + (clause 4.3, Tables 2–5). Any **population fractile** $Q$ follows from the 28 + standard-normal quantile $z(Q)$ (clause 4.4): $\Delta H_Q = \Delta H_{md} + 29 + z(Q)\,s$, using $s_u$ when $z \ge 0$ and $s_l$ otherwise. 30 + 31 + ```python 32 + import phonometry as ph 33 + 34 + # Median threshold shift of a 65-year-old man, all audiometric frequencies. 35 + result = ph.age_threshold(65, "male", fractile=0.5) 36 + print(result.median.round(1)) # [ 6.6 7.6 8. 9. 10.4 13.4 16.3 21.6 26.2 33.7 39.5] 37 + print(result.median[8].round(1)) # 26.2 dB at 4000 Hz 38 + 39 + # The worst-hearing decile (90th percentile) at 4000 Hz: 40 + print(ph.age_threshold(65, "male", fractile=0.9).threshold[8].round(1)) # 50.3 41 + ``` 42 + 43 + The loss is largest at the high frequencies and grows with age — the classic 44 + downward-sloping presbycusis audiogram. Men and women follow different 45 + coefficients (the `sex` argument), and a subset of the audiometric frequencies 46 + can be requested with `frequencies=`. 47 + 48 + ## 2. Reference threshold of hearing (ISO 389-7) 49 + 50 + The audiometric zero is not a fixed sound pressure level: it depends on how the 51 + sound reaches the listener. ISO 389-7:2006 Table 1 gives the reference 52 + threshold for **free-field** (frontal incidence) and **diffuse-field** 53 + listening. 54 + 55 + ```python 56 + import phonometry as ph 57 + 58 + print(ph.reference_threshold("free-field")) 59 + # [22.1 11.4 4.4 2.4 2.4 2.4 -1.3 -5.8 -5.4 4.3 12.6] 60 + print(ph.reference_threshold("diffuse-field")[4]) # 0.8 dB at 1000 Hz 61 + ``` 62 + 63 + The two fields agree at low frequencies and diverge above about 1 kHz, where 64 + the ear-canal resonance and head diffraction make the frontal free field the 65 + more sensitive condition (a lower threshold) around 3–4 kHz. 66 + 67 + <picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/hearing_threshold_dark.png"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/hearing_threshold.png" alt="Two panels. Left: the ISO 7029 median hearing-threshold deviation for men at ages 20, 40, 60 and 80, plotted on an inverted (audiogram) axis so worse hearing falls lower, with the 10 to 90 percent fractile band shaded around the 70-year curve; the loss deepens toward the high frequencies and with age. Right: the ISO 389-7 reference threshold of hearing for free-field and diffuse-field listening, which coincide below 1 kHz and diverge above it, dipping to a minimum near 3 to 4 kHz" width="96%"></picture> 68 + 69 + <details> 70 + <summary>Show the code for this figure</summary> 71 + 72 + ```python 73 + import matplotlib.pyplot as plt 74 + import phonometry as ph 75 + from phonometry.hearing import AUDIOMETRIC_FREQUENCIES as f 76 + 77 + # One line for the age distribution: 78 + ph.age_threshold(70, "male", 0.5).plot() 79 + plt.show() 80 + 81 + # By hand, both panels: 82 + fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 5)) 83 + for age in (20, 40, 60, 80): 84 + r = ph.age_threshold(age, "male", 0.5) 85 + ax1.plot(f, r.median, "o-", label=f"{age} yr") 86 + ax1.set_xscale("log"); ax1.invert_yaxis(); ax1.legend() 87 + 88 + ax2.plot(f, ph.reference_threshold("free-field"), "o-", label="Free-field") 89 + ax2.plot(f, ph.reference_threshold("diffuse-field"), "s--", label="Diffuse-field") 90 + ax2.set_xscale("log"); ax2.legend() 91 + plt.show() 92 + ``` 93 + 94 + </details> 95 + 96 + The `AgeThresholdResult` carries the `median`, the `spread_upper` and 97 + `spread_lower`, and the `threshold` at the requested fractile, and its 98 + `.plot()` draws the median with the 10–90 % band. The noise-induced permanent 99 + threshold shift of ISO 1999 — which adds a noise component on top of this age 100 + component — is a separate topic. 101 + 102 + --- 103 + 104 + **Standards.** ISO 7029:2017, *Statistical distribution of hearing thresholds 105 + related to age and gender* — the median (clause 4.2, Table 1), the spread 106 + around the median (clause 4.3, Tables 2–5) and its application (clause 4.4). 107 + ISO 389-7:2006, *Reference zero for the calibration of audiometric equipment — 108 + Reference threshold of hearing under free-field and diffuse-field listening 109 + conditions* (Table 1).
+21
scripts/conformance_report.py
··· 1379 1379 return numeric(ref.RN_RC35_LMF, ph.room_criterion(ph.rc_curve(35.0)).lmf, 1e-9, places=3) 1380 1380 1381 1381 1382 + _HEAR = "Hearing threshold (ISO 7029 / ISO 389-7)" 1383 + 1384 + 1385 + @register(_HEAR, "ISO 7029:2017 Table 1", "Median threshold, male age 60 at 4 kHz") 1386 + def _chk_hearing_median() -> Outcome: 1387 + value = float(ph.age_threshold(60, "male", 0.5).median[8]) 1388 + return numeric(ref.HEARING_MEDIAN_MALE_60_4KHZ, value, 1e-3, unit="dB", places=3) 1389 + 1390 + 1391 + @register(_HEAR, "ISO 7029:2017 Table 2", "Upper spread su, male age 60 at 1 kHz") 1392 + def _chk_hearing_spread() -> Outcome: 1393 + value = float(ph.age_threshold(60, "male", 0.5).spread_upper[4]) 1394 + return numeric(ref.HEARING_SU_MALE_60_1KHZ, value, 1e-3, unit="dB", places=3) 1395 + 1396 + 1397 + @register(_HEAR, "ISO 389-7:2006 Table 1", "Free-field reference threshold at 1 kHz") 1398 + def _chk_hearing_reference() -> Outcome: 1399 + value = float(ph.reference_threshold("free-field")[4]) 1400 + return numeric(ref.HEARING_REF_FREE_1KHZ, value, 1e-9, unit="dB", places=3) 1401 + 1402 + 1382 1403 # =========================================================================== 1383 1404 # Markdown rendering 1384 1405 # ===========================================================================
+74
scripts/generate_diagrams.py
··· 69 69 "H siseo: una banda ≥ 1000 Hz supera RC en > 3 dB", 70 70 "N neutral: within both tolerances": 71 71 "N neutro: dentro de ambas tolerancias", 72 + # Hearing threshold (ISO 7029 / ISO 389-7) 73 + "Hearing-threshold model (ISO 7029 age distribution, ISO 389-7 zero)": 74 + "Modelo del umbral de audición (ISO 7029 por edad, cero ISO 389-7)", 75 + "Age Y, sex, population fractile Q": 76 + "Edad Y, sexo, fractil poblacional Q", 77 + "audiometric frequencies 125 Hz – 8000 Hz": 78 + "frecuencias audiométricas 125 Hz – 8000 Hz", 79 + "Median deviation from age 18 (ISO 7029, 4.2)": 80 + "Desviación mediana respecto a los 18 años (ISO 7029, 4.2)", 81 + "dHmd = a · (Y − 18) ^ b (Table 1, by sex)": 82 + "dHmd = a · (Y − 18) ^ b (Tabla 1, por sexo)", 83 + "Spread su / sl (ISO 7029, 4.3)": 84 + "Dispersión su / sl (ISO 7029, 4.3)", 85 + "degree-5 polynomials in (Y − 18) (Tables 2–5)": 86 + "polinomios de grado 5 en (Y − 18) (Tablas 2–5)", 87 + "Fractile threshold (ISO 7029, 4.4)": 88 + "Umbral del fractil (ISO 7029, 4.4)", 89 + "dHQ = dHmd + z(Q) * s (su if Q >= 0.5, else sl)": 90 + "dHQ = dHmd + z(Q) * s (su si Q >= 0.5, si no sl)", 91 + "Expected hearing threshold level (dB HL)": 92 + "Nivel del umbral de audición esperado (dB HL)", 93 + "referenced to the audiometric zero": 94 + "referido al cero audiométrico", 95 + "Audiometric zero = ISO 389-7 reference threshold": 96 + "Cero audiométrico = umbral de referencia ISO 389-7", 97 + "free-field / diffuse-field (Table 1) — the dB HL / dB SPL zero": 98 + "campo libre / campo difuso (Tabla 1) — el cero dB HL / dB SPL", 72 99 "Speech Ei'": "Habla Ei'", 73 100 "Noise Ni'": "Ruido Ni'", 74 101 "Threshold Ti'": "Umbral Ti'", ··· 2200 2227 s.text(rxc, 525, "RC-NN(A)", 23, th.fg, "middle", bold=True) 2201 2228 2202 2229 2230 + def _d_hearing_threshold(s: SVG, th: Theme) -> None: 2231 + """Hearing-threshold model: ISO 7029 age distribution + ISO 389-7 zero.""" 2232 + cx = 450.0 2233 + # --- Inputs -------------------------------------------------------------- 2234 + iw, ih = 540.0, 62.0 2235 + s.rect(cx - iw / 2, 56, iw, ih, th.panel, th.fg, rx=10, sw=2) 2236 + s.text(cx, 84, "Age Y, sex, population fractile Q", 20, th.fg, 2237 + "middle", bold=True) 2238 + s.text(cx, 106, "audiometric frequencies 125 Hz – 8000 Hz", 15, th.muted, 2239 + "middle") 2240 + s.arrow(cx, 118, cx, 152, th.fg, 1.8) 2241 + 2242 + bw, bh = 620.0, 60.0 2243 + x0 = cx - bw / 2 2244 + 2245 + def _step(y: float, l1: str, l2: str, color: str) -> None: 2246 + s.rect(x0, y, bw, bh, th.panel, color, rx=10, sw=2) 2247 + s.text(cx, y + 26, l1, 18, th.fg, "middle", bold=True) 2248 + s.text(cx, y + 47, l2, 14, th.muted, "middle") 2249 + 2250 + # --- ISO 7029 chain ------------------------------------------------------ 2251 + _step(152, "Median deviation from age 18 (ISO 7029, 4.2)", 2252 + "dHmd = a · (Y − 18) ^ b (Table 1, by sex)", th.primary) 2253 + _step(244, "Spread su / sl (ISO 7029, 4.3)", 2254 + "degree-5 polynomials in (Y − 18) (Tables 2–5)", th.fg) 2255 + _step(336, "Fractile threshold (ISO 7029, 4.4)", 2256 + "dHQ = dHmd + z(Q) * s (su if Q >= 0.5, else sl)", th.fg) 2257 + s.arrow(cx, 212, cx, 244, th.fg, 1.8) 2258 + s.arrow(cx, 304, cx, 336, th.fg, 1.8) 2259 + s.arrow(cx, 396, cx, 430, th.fg, 1.8) 2260 + 2261 + # --- Output + ISO 389-7 reference --------------------------------------- 2262 + s.rect(x0, 430, bw, 58, "none", th.primary, rx=10, sw=2.4) 2263 + s.text(cx, 456, "Expected hearing threshold level (dB HL)", 19, th.fg, 2264 + "middle", bold=True) 2265 + s.text(cx, 476, "referenced to the audiometric zero", 14, th.primary, 2266 + "middle") 2267 + s.rect(x0, 506, bw, 52, th.panel, th.secondary, rx=10, sw=2) 2268 + s.text(cx, 530, "Audiometric zero = ISO 389-7 reference threshold", 2269 + 17, th.fg, "middle", bold=True) 2270 + s.text(cx, 549, "free-field / diffuse-field (Table 1) — the dB HL / dB SPL zero", 2271 + 14, th.muted, "middle") 2272 + 2273 + 2203 2274 DIAGRAMS = { 2204 2275 "diagram_calibration_setup": (_d1, "Calibration chain — from calibrator to physical units", 560), 2205 2276 "diagram_env_measurement": (_d2, "Environmental noise measurement positions (ISO 1996-2)", 560), ··· 2258 2329 "diagram_room_noise": ( 2259 2330 _d_room_noise, 2260 2331 "Room-noise rating methods (ANSI/ASA S12.2-2019): NC and RC Mark II", 580), 2332 + "diagram_hearing_threshold": ( 2333 + _d_hearing_threshold, 2334 + "Hearing-threshold model (ISO 7029 age distribution, ISO 389-7 zero)", 600), 2261 2335 } 2262 2336 2263 2337
+72
scripts/generate_graphs.py
··· 4 4 from typing import Any 5 5 6 6 import matplotlib.pyplot as plt 7 + import matplotlib.ticker as mticker 7 8 import numpy as np 8 9 from scipy import signal as scipy_signal 9 10 ··· 205 206 "Nivel de presi\u00f3n sonora por banda de octava [dB]", 206 207 "Rumble tol. (+5 dB)": "Tol. retumbo (+5 dB)", 207 208 "Hiss tol. (+3 dB)": "Tol. siseo (+3 dB)", 209 + "ISO 7029 — age-related threshold (male)": 210 + "ISO 7029 — umbral por edad (hombres)", 211 + "ISO 389-7 — reference threshold of hearing": 212 + "ISO 389-7 — umbral de referencia de la audición", 213 + "Audiometric frequency [Hz]": "Frecuencia audiométrica [Hz]", 214 + "Median threshold deviation from age 18 [dB]": 215 + "Desviación mediana del umbral respecto a los 18 años [dB]", 216 + "Reference threshold [dB]": "Umbral de referencia [dB]", 217 + "Free-field (frontal)": "Campo libre (frontal)", 218 + "Diffuse-field": "Campo difuso", 208 219 "Sound Intensity with a p-p Probe (IEC 61043)": 209 220 "Intensidad sonora con sonda p-p (IEC 61043)", 210 221 "Plane wave: Lp \u2248 LI": "Onda plana: Lp \u2248 LI", ··· 431 442 r"Criterios de sala Mark II RC-\1"), 432 443 (r"^Tangent @ (.+) Hz$", r"Tangente @ \1 Hz"), 433 444 (r"^Reference RC-(.+)$", r"Referencia RC-\1"), 445 + (r"^(\d+) yr$", r"\1 años"), 446 + (r"^10-90 % band \((\d+) yr\)$", r"banda 10-90 % (\1 años)"), 434 447 ] 435 448 436 449 ··· 3473 3486 plt.close() 3474 3487 3475 3488 3489 + def generate_hearing_threshold(output_dir: str) -> None: 3490 + """ISO 7029 age-related threshold and ISO 389-7 reference threshold.""" 3491 + print("Generating hearing_threshold.png...") 3492 + from phonometry import age_threshold, reference_threshold 3493 + from phonometry.hearing import AUDIOMETRIC_FREQUENCIES 3494 + 3495 + freqs = AUDIOMETRIC_FREQUENCIES 3496 + fig, (ax_age, ax_ref) = plt.subplots(1, 2, figsize=(12.5, 5.6)) 3497 + 3498 + # --- Left: ISO 7029 median threshold by age (male) + 10-90 % band @70. --- 3499 + ages = [(20, COLOR_GRID), (40, "#7f7f7f"), (60, COLOR_PRIMARY), 3500 + (80, COLOR_SECONDARY)] 3501 + for age, color in ages: 3502 + r = age_threshold(age, "male", 0.5) 3503 + ax_age.plot(freqs, r.median, "o-", color=color, label=f"{age} yr") 3504 + r70 = age_threshold(70, "male", 0.5) 3505 + z90 = 1.2816 3506 + ax_age.fill_between(freqs, r70.median - z90 * r70.spread_lower, 3507 + r70.median + z90 * r70.spread_upper, 3508 + color=COLOR_PRIMARY, alpha=0.12, 3509 + label="10-90 % band (70 yr)") 3510 + ax_age.set_xscale("log") 3511 + ax_age.set_xticks(list(freqs)) 3512 + ax_age.set_xticklabels([f"{f:g}" for f in freqs], rotation=45, ha="right") 3513 + ax_age.xaxis.set_minor_formatter(mticker.NullFormatter()) 3514 + ax_age.invert_yaxis() 3515 + ax_age.set_xlabel("Audiometric frequency [Hz]") 3516 + ax_age.set_ylabel("Median threshold deviation from age 18 [dB]") 3517 + ax_age.set_title("ISO 7029 — age-related threshold (male)", 3518 + fontweight="bold", pad=10) 3519 + ax_age.grid(which="both", color=COLOR_GRID, linestyle="-", alpha=0.4) 3520 + ax_age.set_axisbelow(True) 3521 + ax_age.legend(loc="lower left") 3522 + 3523 + # --- Right: ISO 389-7 reference threshold, free vs diffuse field. --- 3524 + ax_ref.plot(freqs, reference_threshold("free-field"), "o-", 3525 + color=COLOR_PRIMARY, label="Free-field (frontal)") 3526 + ax_ref.plot(freqs, reference_threshold("diffuse-field"), "s--", 3527 + color=COLOR_SECONDARY, label="Diffuse-field") 3528 + ax_ref.set_xscale("log") 3529 + ax_ref.set_xticks(list(freqs)) 3530 + ax_ref.set_xticklabels([f"{f:g}" for f in freqs], rotation=45, ha="right") 3531 + ax_ref.xaxis.set_minor_formatter(mticker.NullFormatter()) 3532 + ax_ref.set_xlabel("Audiometric frequency [Hz]") 3533 + ax_ref.set_ylabel("Reference threshold [dB]") 3534 + ax_ref.set_title("ISO 389-7 — reference threshold of hearing", 3535 + fontweight="bold", pad=10) 3536 + ax_ref.grid(which="both", color=COLOR_GRID, linestyle="-", alpha=0.4) 3537 + ax_ref.set_axisbelow(True) 3538 + ax_ref.legend(loc="upper left") 3539 + 3540 + plt.tight_layout() 3541 + plt.savefig(themed_path(output_dir, "hearing_threshold.png")) 3542 + plt.close() 3543 + 3544 + 3476 3545 def generate_all(img_dir: str) -> None: 3477 3546 """Generate every documentation figure for the currently active theme.""" 3478 3547 generate_filter_type_comparison(img_dir) ··· 3550 3619 3551 3620 # Room-noise criteria (ANSI S12.2-2019): NC tangency and RC Mark II. 3552 3621 generate_room_noise_criteria(img_dir) 3622 + 3623 + # Hearing threshold (ISO 7029 age-related, ISO 389-7 reference). 3624 + generate_hearing_threshold(img_dir) 3553 3625 3554 3626 # Psychoacoustics / open-plan plots (sharpness weighting, spatial decay) 3555 3627 generate_sharpness_weighting(img_dir)
+1 -1
site/astro.config.mjs
··· 291 291 { 292 292 label: 'Perception & speech', 293 293 translations: { es: 'Percepción e inteligibilidad' }, 294 - items: ['guides/psychoacoustics', 'guides/speech-intelligibility'], 294 + items: ['guides/psychoacoustics', 'guides/speech-intelligibility', 'guides/hearing-threshold'], 295 295 }, 296 296 { 297 297 label: 'Sound power & intensity',
+112
site/src/content/docs/es/guides/hearing-threshold.md
··· 1 + --- 2 + title: "Umbral de audición (edad y cero de referencia)" 3 + description: "La distribución del umbral de audición por edad de ISO 7029:2017 (mediana, dispersión y fractiles poblacionales por edad y sexo) y el umbral de referencia de la audición en campo libre y campo difuso de ISO 389-7:2006, sobre las frecuencias audiométricas de 125 Hz a 8000 Hz." 4 + --- 5 + 6 + Dos normas describen dónde se sitúa el umbral de audición. **ISO 7029:2017** da 7 + la **distribución estadística del umbral de audición con la edad** para una 8 + población otológicamente normal — la pérdida lenta y primero en altas 9 + frecuencias conocida como presbiacusia. **ISO 389-7:2006** fija el **umbral de 10 + referencia de la audición**, el cero audiométrico (0 dB HL) expresado como nivel 11 + de presión sonora en escucha de campo libre y campo difuso. Ambas se definen 12 + sobre las frecuencias audiométricas de 125 Hz a 8000 Hz. 13 + 14 + <img class="light-only" src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/diagram_hearing_threshold_es.svg" alt="El modelo del umbral de audición: la edad, el sexo y un fractil poblacional alimentan la cadena de ISO 7029 — la desviación mediana respecto a los 18 años, las dispersiones superior e inferior su y sl, y el umbral del fractil — dando el nivel del umbral de audición esperado en dB HL, referido al cero audiométrico de campo libre o campo difuso de ISO 389-7" style="width:82%"><img class="dark-only" src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/diagram_hearing_threshold_es_dark.svg" alt="El modelo del umbral de audición: la edad, el sexo y un fractil poblacional alimentan la cadena de ISO 7029 — la desviación mediana respecto a los 18 años, las dispersiones superior e inferior su y sl, y el umbral del fractil — dando el nivel del umbral de audición esperado en dB HL, referido al cero audiométrico de campo libre o campo difuso de ISO 389-7" style="width:82%"> 15 + 16 + ## 1. Umbral por edad (ISO 7029) 17 + 18 + Para una persona mayor de 18 años, la desviación **mediana** del umbral de 19 + audición respecto al valor a los 18 años crece como una ley de potencias de la 20 + edad (ISO 7029, cláusula 4.2, Tabla 1): 21 + 22 + $$ 23 + \Delta H_{md} = a\,(Y - 18)^{b}, 24 + $$ 25 + 26 + con coeficientes $a$, $b$ por frecuencia y sexo. La dispersión en torno a la 27 + mediana se modela con dos semi-gaussianas cuyas desviaciones típicas $s_u$ (peor 28 + que la mediana) y $s_l$ (mejor) son polinomios de grado cinco en $(Y - 18)$ 29 + (cláusula 4.3, Tablas 2–5). Cualquier **fractil poblacional** $Q$ se obtiene del 30 + cuantil normal estándar $z(Q)$ (cláusula 4.4): $\Delta H_Q = \Delta H_{md} + 31 + z(Q)\,s$, usando $s_u$ cuando $z \ge 0$ y $s_l$ en caso contrario. 32 + 33 + ```python 34 + import phonometry as ph 35 + 36 + # Median threshold shift of a 65-year-old man, all audiometric frequencies. 37 + result = ph.age_threshold(65, "male", fractile=0.5) 38 + print(result.median.round(1)) # [ 6.6 7.6 8. 9. 10.4 13.4 16.3 21.6 26.2 33.7 39.5] 39 + print(result.median[8].round(1)) # 26.2 dB at 4000 Hz 40 + 41 + # The worst-hearing decile (90th percentile) at 4000 Hz: 42 + print(ph.age_threshold(65, "male", fractile=0.9).threshold[8].round(1)) # 50.3 43 + ``` 44 + 45 + La pérdida es mayor en las altas frecuencias y crece con la edad — el 46 + audiograma descendente clásico de la presbiacusia. Hombres y mujeres siguen 47 + coeficientes distintos (el argumento `sex`), y puede pedirse un subconjunto de 48 + las frecuencias audiométricas con `frequencies=`. 49 + 50 + ## 2. Umbral de referencia de la audición (ISO 389-7) 51 + 52 + El cero audiométrico no es un nivel de presión sonora fijo: depende de cómo 53 + llega el sonido al oyente. La Tabla 1 de ISO 389-7:2006 da el umbral de 54 + referencia para escucha de **campo libre** (incidencia frontal) y **campo 55 + difuso**. 56 + 57 + ```python 58 + import phonometry as ph 59 + 60 + print(ph.reference_threshold("free-field")) 61 + # [22.1 11.4 4.4 2.4 2.4 2.4 -1.3 -5.8 -5.4 4.3 12.6] 62 + print(ph.reference_threshold("diffuse-field")[4]) # 0.8 dB at 1000 Hz 63 + ``` 64 + 65 + Ambos campos coinciden en baja frecuencia y divergen por encima de 1 kHz 66 + aproximadamente, donde la resonancia del canal auditivo y la difracción de la 67 + cabeza hacen del campo libre frontal la condición más sensible (un umbral más 68 + bajo) en torno a 3–4 kHz. 69 + 70 + <img class="light-only" src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/hearing_threshold_es.png" alt="Dos paneles. Izquierda: la desviación mediana del umbral de audición de ISO 7029 para hombres a los 20, 40, 60 y 80 años en un eje de audiograma invertido, con la banda del 10 al 90 por ciento en torno a la curva de 70 años; la pérdida se acentúa hacia las altas frecuencias y con la edad. Derecha: el umbral de referencia de campo libre y campo difuso de ISO 389-7, que coinciden por debajo de 1 kHz y divergen por encima, con un mínimo cerca de 3 a 4 kHz" style="width:96%"><img class="dark-only" src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/hearing_threshold_es_dark.png" alt="Dos paneles. Izquierda: la desviación mediana del umbral de audición de ISO 7029 para hombres a los 20, 40, 60 y 80 años en un eje de audiograma invertido, con la banda del 10 al 90 por ciento en torno a la curva de 70 años; la pérdida se acentúa hacia las altas frecuencias y con la edad. Derecha: el umbral de referencia de campo libre y campo difuso de ISO 389-7, que coinciden por debajo de 1 kHz y divergen por encima, con un mínimo cerca de 3 a 4 kHz" style="width:96%"> 71 + 72 + <details> 73 + <summary>Mostrar el código de esta figura</summary> 74 + 75 + ```python 76 + import matplotlib.pyplot as plt 77 + import phonometry as ph 78 + from phonometry.hearing import AUDIOMETRIC_FREQUENCIES as f 79 + 80 + # One line for the age distribution: 81 + ph.age_threshold(70, "male", 0.5).plot() 82 + plt.show() 83 + 84 + # By hand, both panels: 85 + fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 5)) 86 + for age in (20, 40, 60, 80): 87 + r = ph.age_threshold(age, "male", 0.5) 88 + ax1.plot(f, r.median, "o-", label=f"{age} yr") 89 + ax1.set_xscale("log"); ax1.invert_yaxis(); ax1.legend() 90 + 91 + ax2.plot(f, ph.reference_threshold("free-field"), "o-", label="Free-field") 92 + ax2.plot(f, ph.reference_threshold("diffuse-field"), "s--", label="Diffuse-field") 93 + ax2.set_xscale("log"); ax2.legend() 94 + plt.show() 95 + ``` 96 + 97 + </details> 98 + 99 + El `AgeThresholdResult` lleva la `median`, las dispersiones `spread_upper` y 100 + `spread_lower`, y el `threshold` en el fractil pedido, y su `.plot()` dibuja la 101 + mediana con la banda del 10–90 %. El desplazamiento permanente del umbral 102 + inducido por ruido de ISO 1999 — que añade una componente de ruido sobre esta 103 + componente de edad — es un tema aparte. 104 + 105 + --- 106 + 107 + **Normas.** ISO 7029:2017, *Statistical distribution of hearing thresholds 108 + related to age and gender* — la mediana (cláusula 4.2, Tabla 1), la dispersión 109 + en torno a la mediana (cláusula 4.3, Tablas 2–5) y su aplicación (cláusula 4.4). 110 + ISO 389-7:2006, *Reference zero for the calibration of audiometric equipment — 111 + Reference threshold of hearing under free-field and diffuse-field listening 112 + conditions* (Tabla 1).
+110
site/src/content/docs/guides/hearing-threshold.md
··· 1 + --- 2 + title: "Hearing threshold (age and reference zero)" 3 + description: "The age-related hearing threshold distribution of ISO 7029:2017 (median, spread and population fractiles by age and sex) and the ISO 389-7:2006 free-field and diffuse-field reference threshold of hearing, over the audiometric frequencies from 125 Hz to 8000 Hz." 4 + --- 5 + 6 + Two standards describe where the hearing threshold sits. **ISO 7029:2017** 7 + gives the **statistical distribution of the hearing threshold with age** for an 8 + otologically normal population — the slow, high-frequency-first loss known as 9 + presbycusis. **ISO 389-7:2006** fixes the **reference threshold of hearing**, 10 + the audiometric zero (0 dB HL) expressed as a sound pressure level under 11 + free-field and diffuse-field listening. Both are defined over the audiometric 12 + frequencies from 125 Hz to 8000 Hz. 13 + 14 + <img class="light-only" src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/diagram_hearing_threshold.svg" alt="The hearing-threshold model: age, sex and a population fractile feed the ISO 7029 chain — the median deviation from age 18, the upper and lower spreads su and sl, and the fractile threshold — giving the expected hearing threshold level in dB HL, referenced to the ISO 389-7 free-field or diffuse-field audiometric zero" style="width:82%"><img class="dark-only" src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/diagram_hearing_threshold_dark.svg" alt="The hearing-threshold model: age, sex and a population fractile feed the ISO 7029 chain — the median deviation from age 18, the upper and lower spreads su and sl, and the fractile threshold — giving the expected hearing threshold level in dB HL, referenced to the ISO 389-7 free-field or diffuse-field audiometric zero" style="width:82%"> 15 + 16 + ## 1. Age-related threshold (ISO 7029) 17 + 18 + For a person older than 18, the **median** hearing threshold deviation from the 19 + value at age 18 grows as a power law of age (ISO 7029 clause 4.2, Table 1): 20 + 21 + $$ 22 + \Delta H_{md} = a\,(Y - 18)^{b}, 23 + $$ 24 + 25 + with coefficients $a$, $b$ per frequency and sex. The spread around the median 26 + is modelled by two half-Gaussians whose standard deviations $s_u$ (worse than 27 + the median) and $s_l$ (better) are fifth-degree polynomials in $(Y - 18)$ 28 + (clause 4.3, Tables 2–5). Any **population fractile** $Q$ follows from the 29 + standard-normal quantile $z(Q)$ (clause 4.4): $\Delta H_Q = \Delta H_{md} + 30 + z(Q)\,s$, using $s_u$ when $z \ge 0$ and $s_l$ otherwise. 31 + 32 + ```python 33 + import phonometry as ph 34 + 35 + # Median threshold shift of a 65-year-old man, all audiometric frequencies. 36 + result = ph.age_threshold(65, "male", fractile=0.5) 37 + print(result.median.round(1)) # [ 6.6 7.6 8. 9. 10.4 13.4 16.3 21.6 26.2 33.7 39.5] 38 + print(result.median[8].round(1)) # 26.2 dB at 4000 Hz 39 + 40 + # The worst-hearing decile (90th percentile) at 4000 Hz: 41 + print(ph.age_threshold(65, "male", fractile=0.9).threshold[8].round(1)) # 50.3 42 + ``` 43 + 44 + The loss is largest at the high frequencies and grows with age — the classic 45 + downward-sloping presbycusis audiogram. Men and women follow different 46 + coefficients (the `sex` argument), and a subset of the audiometric frequencies 47 + can be requested with `frequencies=`. 48 + 49 + ## 2. Reference threshold of hearing (ISO 389-7) 50 + 51 + The audiometric zero is not a fixed sound pressure level: it depends on how the 52 + sound reaches the listener. ISO 389-7:2006 Table 1 gives the reference 53 + threshold for **free-field** (frontal incidence) and **diffuse-field** 54 + listening. 55 + 56 + ```python 57 + import phonometry as ph 58 + 59 + print(ph.reference_threshold("free-field")) 60 + # [22.1 11.4 4.4 2.4 2.4 2.4 -1.3 -5.8 -5.4 4.3 12.6] 61 + print(ph.reference_threshold("diffuse-field")[4]) # 0.8 dB at 1000 Hz 62 + ``` 63 + 64 + The two fields agree at low frequencies and diverge above about 1 kHz, where 65 + the ear-canal resonance and head diffraction make the frontal free field the 66 + more sensitive condition (a lower threshold) around 3–4 kHz. 67 + 68 + <img class="light-only" src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/hearing_threshold.png" alt="Two panels. Left: the ISO 7029 median hearing-threshold deviation for men at ages 20, 40, 60 and 80 on an inverted audiogram axis, with the 10 to 90 percent fractile band around the 70-year curve; the loss deepens toward high frequencies and with age. Right: the ISO 389-7 free-field and diffuse-field reference threshold, coinciding below 1 kHz and diverging above, dipping to a minimum near 3 to 4 kHz" style="width:96%"><img class="dark-only" src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/hearing_threshold_dark.png" alt="Two panels. Left: the ISO 7029 median hearing-threshold deviation for men at ages 20, 40, 60 and 80 on an inverted audiogram axis, with the 10 to 90 percent fractile band around the 70-year curve; the loss deepens toward high frequencies and with age. Right: the ISO 389-7 free-field and diffuse-field reference threshold, coinciding below 1 kHz and diverging above, dipping to a minimum near 3 to 4 kHz" style="width:96%"> 69 + 70 + <details> 71 + <summary>Show the code for this figure</summary> 72 + 73 + ```python 74 + import matplotlib.pyplot as plt 75 + import phonometry as ph 76 + from phonometry.hearing import AUDIOMETRIC_FREQUENCIES as f 77 + 78 + # One line for the age distribution: 79 + ph.age_threshold(70, "male", 0.5).plot() 80 + plt.show() 81 + 82 + # By hand, both panels: 83 + fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 5)) 84 + for age in (20, 40, 60, 80): 85 + r = ph.age_threshold(age, "male", 0.5) 86 + ax1.plot(f, r.median, "o-", label=f"{age} yr") 87 + ax1.set_xscale("log"); ax1.invert_yaxis(); ax1.legend() 88 + 89 + ax2.plot(f, ph.reference_threshold("free-field"), "o-", label="Free-field") 90 + ax2.plot(f, ph.reference_threshold("diffuse-field"), "s--", label="Diffuse-field") 91 + ax2.set_xscale("log"); ax2.legend() 92 + plt.show() 93 + ``` 94 + 95 + </details> 96 + 97 + The `AgeThresholdResult` carries the `median`, the `spread_upper` and 98 + `spread_lower`, and the `threshold` at the requested fractile, and its 99 + `.plot()` draws the median with the 10–90 % band. The noise-induced permanent 100 + threshold shift of ISO 1999 — which adds a noise component on top of this age 101 + component — is a separate topic. 102 + 103 + --- 104 + 105 + **Standards.** ISO 7029:2017, *Statistical distribution of hearing thresholds 106 + related to age and gender* — the median (clause 4.2, Table 1), the spread 107 + around the median (clause 4.3, Tables 2–5) and its application (clause 4.4). 108 + ISO 389-7:2006, *Reference zero for the calibration of audiometric equipment — 109 + Reference threshold of hearing under free-field and diffuse-field listening 110 + conditions* (Table 1).
+8
src/phonometry/__init__.py
··· 38 38 ) 39 39 from .loudness_contours import equal_loudness_contour, hearing_threshold, loudness_level 40 40 from .sharpness import sharpness_din, sharpness_din_from_specific 41 + from .hearing import ( 42 + AgeThresholdResult, 43 + age_threshold, 44 + reference_threshold, 45 + ) 41 46 from .room_noise import ( 42 47 NCResult, 43 48 RCResult, ··· 402 407 "rc_curve", 403 408 "NCResult", 404 409 "RCResult", 410 + "age_threshold", 411 + "reference_threshold", 412 + "AgeThresholdResult", 405 413 "sweep_signal", 406 414 "inverse_filter", 407 415 "impulse_response",
+44
src/phonometry/_plotting.py
··· 37 37 from .loudness_moore_glasberg_time import MooreGlasbergTimeVaryingLoudness 38 38 from .room_acoustics import DecayCurve, RoomAcousticsResult 39 39 from .room_ir import ImpulseResponseResult 40 + from .hearing import AgeThresholdResult 40 41 from .room_noise import NCResult, RCResult 41 42 from .tonality_ecma import EcmaTonality 42 43 from .roughness_ecma import EcmaRoughness ··· 80 81 81 82 def _freq_axis(ax: Axes, freqs: np.ndarray) -> None: 82 83 """Configure a logarithmic frequency x-axis labelled with band centres.""" 84 + import matplotlib.ticker as mticker 85 + 83 86 ax.set_xscale("log") 84 87 ax.set_xticks(list(freqs)) 85 88 ax.set_xticklabels([_format_freq(f) for f in freqs], rotation=45, ha="right") 89 + # Suppress the log-scale minor-tick labels (2x10^2, 3x10^2, ...) that would 90 + # otherwise collide with off-decade band centres such as 750 or 1500 Hz. 91 + ax.xaxis.set_minor_formatter(mticker.NullFormatter()) 86 92 ax.set_xlabel("Frequency [Hz]") 87 93 88 94 ··· 1431 1437 ax.legend(loc=_LEGEND_UPPER_RIGHT, fontsize="small") 1432 1438 ax.grid(True, which="both", alpha=0.3) 1433 1439 return ax 1440 + 1441 + 1442 + # --------------------------------------------------------------------------- 1443 + # Age-related hearing threshold (ISO 7029) 1444 + # --------------------------------------------------------------------------- 1445 + 1446 + 1447 + def plot_age_threshold( 1448 + result: "AgeThresholdResult", ax: Axes | None = None, **kwargs: Any 1449 + ) -> Axes: 1450 + """Median age-related hearing threshold with the 10-90 % fractile band. 1451 + 1452 + :param result: An :class:`~phonometry.hearing.AgeThresholdResult`. 1453 + :param ax: Existing axes, or ``None`` to create a figure. 1454 + :param kwargs: Forwarded to the median line ``plot``. 1455 + :return: The axes. 1456 + """ 1457 + ax = ax if ax is not None else _new_axes() 1458 + freqs = np.asarray(result.frequencies, dtype=np.float64) 1459 + median = np.asarray(result.median, dtype=np.float64) 1460 + su = np.asarray(result.spread_upper, dtype=np.float64) 1461 + sl = np.asarray(result.spread_lower, dtype=np.float64) 1462 + z90 = 1.2816 # standard-normal quantile of 0.9 1463 + 1464 + ax.fill_between(freqs, median - z90 * sl, median + z90 * su, 1465 + color="#aec7e8", alpha=0.5, label="10-90 % fractile band") 1466 + kwargs.setdefault("color", "#1f77b4") 1467 + ax.plot(freqs, median, "o-", label="Median", **kwargs) 1468 + if abs(result.fractile - 0.5) > 1e-9: 1469 + ax.plot(freqs, np.asarray(result.threshold, dtype=np.float64), "s--", 1470 + color="#d62728", label=f"Fractile {result.fractile:g}") 1471 + _freq_axis(ax, freqs) 1472 + ax.set_ylabel("Threshold deviation from age 18 [dB]") 1473 + ax.invert_yaxis() # audiogram convention: worse hearing downward 1474 + ax.set_title(f"ISO 7029 hearing threshold — {result.sex}, age {result.age:g}") 1475 + ax.legend(loc=_LEGEND_UPPER_RIGHT, fontsize="small") 1476 + ax.grid(True, which="both", alpha=0.3) 1477 + return ax
+279
src/phonometry/hearing.py
··· 1 + # Copyright (c) 2026. Jose M. Requena-Plens 2 + """ 3 + Age-related hearing threshold (ISO 7029:2017) and audiometric reference zero 4 + (ISO 389-7:2006). 5 + 6 + Implements the statistical distribution of the hearing threshold of an 7 + otologically normal population as a function of age and sex (ISO 7029:2017), 8 + and the reference threshold of hearing under free-field and diffuse-field 9 + listening (ISO 389-7:2006, Table 1), over the audiometric frequencies from 10 + 125 Hz to 8000 Hz. 11 + 12 + ISO 7029 gives the median threshold deviation from the value at age 18 as 13 + ``dHmd = a * (age - 18) ** b`` (clause 4.2, Table 1) and the spread around the 14 + median as two half-Gaussian standard deviations ``su`` (worse than median) and 15 + ``sl`` (better than median), each a fifth-degree polynomial in ``age - 18`` 16 + (clause 4.3, Tables 2-5). A population fractile is obtained by shifting the 17 + median by the standard-normal quantile times the appropriate spread 18 + (clause 4.4 / Annex A). 19 + 20 + The noise-induced permanent threshold shift of ISO 1999 (which combines a noise 21 + component with this age component) is not part of this module. 22 + """ 23 + 24 + from __future__ import annotations 25 + 26 + from dataclasses import dataclass 27 + from typing import TYPE_CHECKING, Any 28 + 29 + import numpy as np 30 + 31 + if TYPE_CHECKING: 32 + from matplotlib.axes import Axes 33 + 34 + from numpy.typing import ArrayLike 35 + 36 + # --------------------------------------------------------------------------- 37 + # Normative constants. 38 + # --------------------------------------------------------------------------- 39 + 40 + #: Audiometric frequencies, in hertz (ISO 7029 Table 1 / ISO 389-7 Table 1). 41 + AUDIOMETRIC_FREQUENCIES: np.ndarray = np.array( 42 + [125.0, 250.0, 500.0, 750.0, 1000.0, 1500.0, 2000.0, 3000.0, 4000.0, 43 + 6000.0, 8000.0], 44 + dtype=np.float64, 45 + ) 46 + 47 + #: Median coefficients ``(a, b)`` of ``dHmd = a*(age-18)**b`` (ISO 7029 Table 1). 48 + _MEDIAN_MALE: np.ndarray = np.array( 49 + [[2.50e-6, 3.841], [1.39e-4, 2.832], [4.59e-4, 2.537], [5.70e-4, 2.512], 50 + [7.02e-4, 2.494], [1.09e-3, 2.446], [1.56e-3, 2.404], [2.54e-3, 2.350], 51 + [3.40e-3, 2.325], [4.53e-3, 2.315], [5.06e-3, 2.328]], 52 + dtype=np.float64, 53 + ) 54 + _MEDIAN_FEMALE: np.ndarray = np.array( 55 + [[6.16e-4, 2.451], [3.98e-4, 2.568], [2.61e-4, 2.708], [2.25e-4, 2.775], 56 + [2.21e-4, 2.805], [2.53e-4, 2.813], [3.12e-4, 2.792], [4.88e-4, 2.728], 57 + [7.37e-4, 2.660], [1.47e-3, 2.539], [2.53e-3, 2.439]], 58 + dtype=np.float64, 59 + ) 60 + 61 + #: Upper-spread ``su`` polynomial coefficients c0..c5 (ISO 7029 Table 2, male). 62 + _SU_MALE: np.ndarray = np.array( 63 + [[4.63, 0.645, -8.85e-2, 3.69e-3, -5.98e-5, 3.39e-7], 64 + [5.27, 0.710, -9.13e-2, 3.64e-3, -5.74e-5, 3.22e-7], 65 + [4.98, 0.751, -9.20e-2, 3.68e-3, -5.84e-5, 3.28e-7], 66 + [4.65, 0.733, -8.81e-2, 3.59e-3, -5.76e-5, 3.24e-7], 67 + [4.42, 0.714, -8.54e-2, 3.57e-3, -5.82e-5, 3.29e-7], 68 + [4.14, 0.679, -8.04e-2, 3.52e-3, -5.89e-5, 3.35e-7], 69 + [4.10, 0.632, -7.53e-2, 3.46e-3, -5.94e-5, 3.40e-7], 70 + [4.29, 0.530, -6.28e-2, 3.09e-3, -5.37e-5, 2.95e-7], 71 + [4.68, 0.455, -5.52e-2, 2.95e-3, -5.30e-5, 2.92e-7], 72 + [5.61, 0.363, -4.72e-2, 2.92e-3, -5.58e-5, 3.12e-7], 73 + [6.62, 0.291, -4.16e-2, 2.92e-3, -5.85e-5, 3.33e-7]], 74 + dtype=np.float64, 75 + ) 76 + #: Lower-spread ``sl`` polynomial coefficients c0..c5 (ISO 7029 Table 3, male). 77 + _SL_MALE: np.ndarray = np.array( 78 + [[3.34, 0.131, -2.02e-2, 1.12e-3, -2.28e-5, 1.57e-7], 79 + [3.32, 0.230, -2.54e-2, 1.20e-3, -2.27e-5, 1.46e-7], 80 + [3.43, 0.362, -4.11e-2, 1.87e-3, -3.44e-5, 2.21e-7], 81 + [3.60, 0.384, -4.43e-2, 1.98e-3, -3.55e-5, 2.22e-7], 82 + [3.77, 0.363, -4.19e-2, 1.82e-3, -3.14e-5, 1.89e-7], 83 + [3.93, 0.365, -4.22e-2, 1.79e-3, -2.96e-5, 1.70e-7], 84 + [4.01, 0.387, -4.47e-2, 1.87e-3, -3.02e-5, 1.69e-7], 85 + [4.11, 0.405, -4.56e-2, 1.86e-3, -2.83e-5, 1.46e-7], 86 + [4.09, 0.439, -4.78e-2, 1.92e-3, -2.84e-5, 1.40e-7], 87 + [4.01, 0.497, -4.97e-2, 1.93e-3, -2.67e-5, 1.18e-7], 88 + [3.90, 0.559, -5.62e-2, 2.40e-3, -3.92e-5, 2.29e-7]], 89 + dtype=np.float64, 90 + ) 91 + #: Upper-spread ``su`` polynomial coefficients c0..c5 (ISO 7029 Table 4, female). 92 + _SU_FEMALE: np.ndarray = np.array( 93 + [[5.05, 0.400, -4.60e-2, 1.73e-3, -2.75e-5, 1.71e-7], 94 + [5.01, 0.481, -4.88e-2, 1.80e-3, -2.81e-5, 1.67e-7], 95 + [4.68, 0.510, -5.16e-2, 1.95e-3, -3.07e-5, 1.77e-7], 96 + [4.45, 0.511, -5.25e-2, 2.03e-3, -3.18e-5, 1.81e-7], 97 + [4.34, 0.492, -5.15e-2, 2.03e-3, -3.18e-5, 1.78e-7], 98 + [4.23, 0.479, -5.12e-2, 2.07e-3, -3.26e-5, 1.80e-7], 99 + [4.26, 0.456, -4.91e-2, 2.01e-3, -3.16e-5, 1.70e-7], 100 + [4.36, 0.476, -5.15e-2, 2.19e-3, -3.51e-5, 1.91e-7], 101 + [4.61, 0.477, -5.07e-2, 2.19e-3, -3.51e-5, 1.88e-7], 102 + [5.22, 0.483, -4.83e-2, 2.13e-3, -3.39e-5, 1.74e-7], 103 + [5.84, 0.516, -4.89e-2, 2.18e-3, -3.49e-5, 1.77e-7]], 104 + dtype=np.float64, 105 + ) 106 + #: Lower-spread ``sl`` polynomial coefficients c0..c5 (ISO 7029 Table 5, female). 107 + _SL_FEMALE: np.ndarray = np.array( 108 + [[3.64, 0.047, 2.28e-3, -6.68e-5, -8.72e-7, 2.30e-8], 109 + [3.11, 0.226, -7.71e-3, 9.83e-5, -7.11e-7, 9.02e-9], 110 + [2.98, 0.338, -1.74e-2, 3.53e-4, -2.78e-6, 1.01e-8], 111 + [3.03, 0.378, -2.24e-2, 5.20e-4, -4.60e-6, 1.50e-8], 112 + [3.15, 0.382, -2.39e-2, 5.62e-4, -4.60e-6, 9.87e-9], 113 + [3.32, 0.387, -2.64e-2, 6.71e-4, -5.76e-6, 1.07e-8], 114 + [3.47, 0.392, -2.84e-2, 7.79e-4, -7.35e-6, 1.71e-8], 115 + [3.69, 0.392, -2.96e-2, 8.44e-4, -7.55e-6, 8.16e-9], 116 + [3.84, 0.402, -3.17e-2, 9.99e-4, -1.08e-5, 2.99e-8], 117 + [4.04, 0.403, -3.15e-2, 1.06e-3, -1.21e-5, 3.44e-8], 118 + [4.15, 0.413, -3.01e-2, 1.00e-3, -9.97e-6, 8.74e-9]], 119 + dtype=np.float64, 120 + ) 121 + 122 + _MEDIAN = {"male": _MEDIAN_MALE, "female": _MEDIAN_FEMALE} 123 + _SU = {"male": _SU_MALE, "female": _SU_FEMALE} 124 + _SL = {"male": _SL_MALE, "female": _SL_FEMALE} 125 + SEXES: tuple[str, ...] = ("male", "female") 126 + 127 + #: Reference threshold of hearing, in dB, free-field then diffuse-field, at 128 + #: :data:`AUDIOMETRIC_FREQUENCIES` (ISO 389-7:2006 Table 1). 129 + _REFERENCE_FREE: np.ndarray = np.array( 130 + [22.1, 11.4, 4.4, 2.4, 2.4, 2.4, -1.3, -5.8, -5.4, 4.3, 12.6], 131 + dtype=np.float64, 132 + ) 133 + _REFERENCE_DIFFUSE: np.ndarray = np.array( 134 + [22.1, 11.4, 3.8, 1.2, 0.8, 1.0, -1.5, -4.0, -3.8, 1.4, 6.8], 135 + dtype=np.float64, 136 + ) 137 + _REFERENCE = {"free-field": _REFERENCE_FREE, "diffuse-field": _REFERENCE_DIFFUSE} 138 + FIELDS: tuple[str, ...] = ("free-field", "diffuse-field") 139 + 140 + _REFERENCE_AGE = 18.0 # lower age limit of the ISO 7029 formulae. 141 + 142 + 143 + @dataclass(frozen=True) 144 + class AgeThresholdResult: 145 + """Age-related hearing threshold distribution (ISO 7029:2017). 146 + 147 + All arrays are in dB and aligned with :data:`AUDIOMETRIC_FREQUENCIES`. 148 + 149 + :ivar age: Listener age, in years. 150 + :ivar sex: ``"male"`` or ``"female"``. 151 + :ivar fractile: Population fractile of ``threshold`` (0-1). 152 + :ivar frequencies: Audiometric frequencies, in hertz. 153 + :ivar median: Median threshold deviation from age 18 (clause 4.2). 154 + :ivar spread_upper: Upper half-Gaussian standard deviation ``su``. 155 + :ivar spread_lower: Lower half-Gaussian standard deviation ``sl``. 156 + :ivar threshold: Threshold deviation at ``fractile`` (clause 4.4). 157 + """ 158 + 159 + age: float 160 + sex: str 161 + fractile: float 162 + frequencies: np.ndarray 163 + median: np.ndarray 164 + spread_upper: np.ndarray 165 + spread_lower: np.ndarray 166 + threshold: np.ndarray 167 + 168 + def plot(self, ax: "Axes | None" = None, **kwargs: Any) -> "Axes": 169 + """Plot the median threshold with the fractile band over frequency. 170 + 171 + Requires matplotlib (``pip install phonometry[plot]``); returns the 172 + :class:`~matplotlib.axes.Axes`. 173 + """ 174 + from ._plotting import plot_age_threshold 175 + 176 + return plot_age_threshold(self, ax=ax, **kwargs) 177 + 178 + 179 + def _select(values: np.ndarray, frequencies: ArrayLike | None) -> np.ndarray: 180 + """Return ``values`` for a requested frequency subset, or all of them.""" 181 + if frequencies is None: 182 + return values.copy() 183 + fr = np.atleast_1d(np.asarray(frequencies, dtype=np.float64)) 184 + idx = [] 185 + for f in fr: 186 + matches = np.isclose(AUDIOMETRIC_FREQUENCIES, f, rtol=1e-3) 187 + if not matches.any(): 188 + raise ValueError( 189 + f"frequency {f} Hz is not an ISO 7029 audiometric frequency " 190 + f"(125 Hz - 8000 Hz)." 191 + ) 192 + idx.append(int(np.argmax(matches))) 193 + return values[idx] 194 + 195 + 196 + def _spread(coeffs: np.ndarray, u: float) -> np.ndarray: 197 + """Evaluate the degree-5 spread polynomials (ascending powers) at ``u``.""" 198 + powers = u ** np.arange(coeffs.shape[1], dtype=np.float64) 199 + return coeffs @ powers 200 + 201 + 202 + def age_threshold( 203 + age: float, 204 + sex: str = "male", 205 + fractile: float = 0.5, 206 + frequencies: ArrayLike | None = None, 207 + ) -> AgeThresholdResult: 208 + """Age-related hearing threshold distribution (ISO 7029:2017). 209 + 210 + Returns, per audiometric frequency, the median threshold deviation from the 211 + value at age 18 (clause 4.2), the upper/lower half-Gaussian spreads 212 + (clause 4.3) and the threshold at the requested population ``fractile`` 213 + (clause 4.4): ``median + z * spread`` where ``z`` is the standard-normal 214 + quantile of ``fractile`` and the spread is the upper one for ``z >= 0`` 215 + (worse than the median) or the lower one otherwise. 216 + 217 + :param age: Listener age, in years (must be at least 18). The standard's 218 + formulae are established up to 80 years for frequencies at or below 219 + 2000 Hz and up to 70 years above; ages beyond that extrapolate. 220 + :param sex: ``"male"`` or ``"female"``. 221 + :param fractile: Population fractile in the open interval (0, 1); ``0.5`` 222 + gives the median. 223 + :param frequencies: Optional subset of the audiometric frequencies, in 224 + hertz; ``None`` uses all eleven (125 Hz - 8000 Hz). 225 + :return: An :class:`AgeThresholdResult` with the distribution and ``.plot()``. 226 + :raises ValueError: for an age below 18, an unknown sex, a fractile outside 227 + (0, 1), or an unknown frequency. 228 + """ 229 + if age < _REFERENCE_AGE: 230 + raise ValueError( 231 + f"age must be at least {_REFERENCE_AGE:.0f} years (the ISO 7029 " 232 + f"lower limit); got {age}." 233 + ) 234 + if sex not in _MEDIAN: 235 + raise ValueError(f"sex must be one of {SEXES}; got {sex!r}.") 236 + if not 0.0 < fractile < 1.0: 237 + raise ValueError(f"fractile must be in (0, 1); got {fractile}.") 238 + 239 + from scipy.special import ndtri # standard-normal quantile 240 + 241 + u = float(age) - _REFERENCE_AGE 242 + med_coeffs = _MEDIAN[sex] 243 + median = med_coeffs[:, 0] * u ** med_coeffs[:, 1] 244 + su = _spread(_SU[sex], u) 245 + sl = _spread(_SL[sex], u) 246 + 247 + z = float(ndtri(fractile)) 248 + threshold = median + z * (su if z >= 0.0 else sl) 249 + 250 + return AgeThresholdResult( 251 + age=float(age), 252 + sex=sex, 253 + fractile=float(fractile), 254 + frequencies=_select(AUDIOMETRIC_FREQUENCIES, frequencies), 255 + median=_select(median, frequencies), 256 + spread_upper=_select(su, frequencies), 257 + spread_lower=_select(sl, frequencies), 258 + threshold=_select(threshold, frequencies), 259 + ) 260 + 261 + 262 + def reference_threshold( 263 + field: str = "free-field", frequencies: ArrayLike | None = None 264 + ) -> np.ndarray: 265 + """Reference threshold of hearing (ISO 389-7:2006, Table 1). 266 + 267 + The sound pressure level, in dB, that corresponds to the audiometric zero 268 + (0 dB HL) under the given listening condition, at the audiometric 269 + frequencies. 270 + 271 + :param field: ``"free-field"`` (frontal incidence) or ``"diffuse-field"``. 272 + :param frequencies: Optional subset of the audiometric frequencies, in 273 + hertz; ``None`` uses all eleven (125 Hz - 8000 Hz). 274 + :return: The reference threshold, in dB, aligned with the frequencies. 275 + :raises ValueError: for an unknown field or frequency. 276 + """ 277 + if field not in _REFERENCE: 278 + raise ValueError(f"field must be one of {FIELDS}; got {field!r}.") 279 + return _select(_REFERENCE[field], frequencies)
+11
tests/reference_data.py
··· 469 469 RN_NC40_SELF = 40.0 # NC-40 curve -> tangency rating (Table 1) 470 470 RN_RC31_63HZ = 51.0 # RC-31 curve, 63 Hz octave-band level (Table D.1) 471 471 RN_RC35_LMF = 35.0 # RC-35 curve, mid-frequency average LMF (clause D.4) 472 + 473 + # --------------------------------------------------------------------------- 474 + # Hearing thresholds - ISO 7029:2017 (age) and ISO 389-7:2006 (reference). 475 + # The median deviation follows a*(age-18)**b (Table 1); at 4 kHz for a 60-year 476 + # male it is 20.21 dB. The upper spread su is a degree-5 polynomial (Table 2); 477 + # at 1 kHz age 60 male it is 10.15 dB. The free-field reference threshold at 478 + # 1 kHz is 2.4 dB (ISO 389-7 Table 1). 479 + # --------------------------------------------------------------------------- 480 + HEARING_MEDIAN_MALE_60_4KHZ = 20.2085 # dB, ISO 7029 Table 1 median formula 481 + HEARING_SU_MALE_60_1KHZ = 10.1533 # dB, ISO 7029 Table 2 upper spread 482 + HEARING_REF_FREE_1KHZ = 2.4 # dB, ISO 389-7 Table 1 free-field
+114
tests/test_hearing.py
··· 1 + # Copyright (c) 2026. Jose M. Requena-Plens 2 + """Tests for :mod:`phonometry.hearing` (ISO 7029:2017 and ISO 389-7:2006). 3 + 4 + The age-related model is validated against the standard's own boundary 5 + behaviour (at age 18 the median deviation is zero and the spreads equal the 6 + constant polynomial terms of Tables 2-5) and its monotonic/sex trends, and the 7 + reference threshold against the ISO 389-7 Table 1 values. 8 + """ 9 + 10 + from __future__ import annotations 11 + 12 + import numpy as np 13 + import pytest 14 + 15 + from phonometry import hearing as h 16 + 17 + 18 + def test_frequency_and_table_shapes() -> None: 19 + assert h.AUDIOMETRIC_FREQUENCIES.size == 11 20 + assert h.AUDIOMETRIC_FREQUENCIES[0] == 125.0 21 + assert h.AUDIOMETRIC_FREQUENCIES[-1] == 8000.0 22 + for table in (h._SU_MALE, h._SL_MALE, h._SU_FEMALE, h._SL_FEMALE): 23 + assert table.shape == (11, 6) 24 + for table in (h._MEDIAN_MALE, h._MEDIAN_FEMALE): 25 + assert table.shape == (11, 2) 26 + 27 + 28 + @pytest.mark.parametrize("sex", ["male", "female"]) 29 + def test_reference_age_is_zero_median(sex: str) -> None: 30 + # ISO 7029 clause 4.2: at age 18 the median deviation is zero everywhere, 31 + # and the spreads reduce to the constant terms c0 of Tables 2-5. 32 + result = h.age_threshold(18, sex, 0.5) 33 + np.testing.assert_allclose(result.median, 0.0, atol=1e-12) 34 + np.testing.assert_allclose(result.threshold, 0.0, atol=1e-12) 35 + np.testing.assert_allclose(result.spread_upper, h._SU[sex][:, 0]) 36 + np.testing.assert_allclose(result.spread_lower, h._SL[sex][:, 0]) 37 + 38 + 39 + def test_median_matches_reference_values() -> None: 40 + # Reference median deviations computed from the Table 1 formula. 41 + male = h.age_threshold(60, "male", 0.5) 42 + assert male.median[4] == pytest.approx(7.8473, abs=1e-3) # 1000 Hz 43 + assert male.median[8] == pytest.approx(20.2085, abs=1e-3) # 4000 Hz 44 + female = h.age_threshold(60, "female", 0.5) 45 + assert female.median[8] == pytest.approx(15.3218, abs=1e-3) 46 + 47 + 48 + def test_median_increases_with_age() -> None: 49 + m = [h.age_threshold(a, "male", 0.5).median[8] for a in (18, 30, 50, 70)] 50 + assert all(x < y for x, y in zip(m, m[1:])) 51 + assert m[0] == pytest.approx(0.0, abs=1e-12) 52 + 53 + 54 + def test_fractiles_bracket_the_median() -> None: 55 + # A worse fractile (0.9) lies above the median; a better one (0.1) below. 56 + median = h.age_threshold(60, "male", 0.5).threshold 57 + worse = h.age_threshold(60, "male", 0.9).threshold 58 + better = h.age_threshold(60, "male", 0.1).threshold 59 + assert np.all(better < median) 60 + assert np.all(worse > median) 61 + 62 + 63 + def test_male_median_exceeds_female_at_high_frequency() -> None: 64 + male = h.age_threshold(70, "male", 0.5).median[8] # 4000 Hz 65 + female = h.age_threshold(70, "female", 0.5).median[8] 66 + assert male > female 67 + 68 + 69 + def test_reference_threshold_matches_iso389_7() -> None: 70 + free = h.reference_threshold("free-field") 71 + diffuse = h.reference_threshold("diffuse-field") 72 + # ISO 389-7:2006 Table 1 (audiometric frequencies). 73 + assert free[4] == pytest.approx(2.4) # 1000 Hz free-field 74 + assert free[10] == pytest.approx(12.6) # 8000 Hz free-field 75 + assert diffuse[4] == pytest.approx(0.8) # 1000 Hz diffuse-field 76 + # Free- and diffuse-field agree at low frequencies, diverge higher up. 77 + assert free[0] == diffuse[0] 78 + assert free[4] != diffuse[4] 79 + 80 + 81 + def test_subset_by_frequency() -> None: 82 + result = h.age_threshold(60, "male", 0.5, frequencies=[1000, 4000]) 83 + np.testing.assert_allclose(result.frequencies, [1000.0, 4000.0]) 84 + assert result.median[0] == pytest.approx(7.8473, abs=1e-3) 85 + ref = h.reference_threshold("free-field", frequencies=[1000, 8000]) 86 + np.testing.assert_allclose(ref, [2.4, 12.6]) 87 + 88 + 89 + def test_invalid_inputs_raise() -> None: 90 + with pytest.raises(ValueError, match="at least 18"): 91 + h.age_threshold(10, "male") 92 + with pytest.raises(ValueError, match="sex must be"): 93 + h.age_threshold(40, "other") 94 + with pytest.raises(ValueError, match="fractile"): 95 + h.age_threshold(40, "male", fractile=1.0) 96 + with pytest.raises(ValueError, match="audiometric frequency"): 97 + h.age_threshold(40, "male", frequencies=[777.0]) 98 + with pytest.raises(ValueError, match="field must be"): 99 + h.reference_threshold("random") 100 + 101 + 102 + def test_result_fields_and_plot() -> None: 103 + import matplotlib 104 + 105 + matplotlib.use("Agg") 106 + import matplotlib.pyplot as plt 107 + 108 + result = h.age_threshold(55, "female", 0.75) 109 + assert result.age == 55.0 110 + assert result.sex == "female" 111 + assert result.threshold.shape == (11,) 112 + ax = result.plot() 113 + assert isinstance(ax, plt.Axes) 114 + plt.close("all")