R/nalr.R

Defines functions prescribe_compression calculate_nal_sspl90 calculate_open_nl_gain calculate_nalr_gain

Documented in prescribe_compression

calculate_nalr_gain <- function(freq, threshold) {
  # NAL-R formula requires Pure Tone Average (PTA) at 500, 1000, 2000 Hz
  pta_freqs <- c(500, 1000, 2000)
  
  # Find or interpolate thresholds at PTA frequencies
  if (all(pta_freqs %in% freq)) {
    pta_thresh <- threshold[match(pta_freqs, freq)]
  } else {
    pta_thresh <- approx(x = log10(freq), y = threshold, xout = log10(pta_freqs), rule = 2)$y
  }
  
  pta <- mean(pta_thresh, na.rm = TRUE)
  
  # NAL-R X constant
  X <- 0.15 * pta
  
  # NAL-R C correction factors
  c_freqs <- c(250, 500, 1000, 2000, 3000, 4000, 6000, 8000)
  c_vals <- c(-17, -8, 1, -1, -2, -2, -2, -2)
  
  # Interpolate C values to the requested frequencies (linear interpolation on log frequency scale)
  c_interp <- approx(x = log10(c_freqs), y = c_vals, xout = log10(freq), rule = 2)$y
  
  # Calculate Real Ear Insertion Gain (REIG)
  # IG = X + 0.31 * HT + C
  ig <- X + 0.31 * threshold + c_interp
  
  # Gain should not be negative for a typical linear prescription
  ig <- pmax(ig, 0, na.rm = TRUE)
  
  return(ig)
}

calculate_open_nl_gain <- function(freq, threshold, input_level, gender = "male", experience = "experienced", config = "bilateral", age = "adult", coupling = "custom_occluded", module = "standard", ldl = NULL, age_years = NULL, age_months = NULL, loss = NULL, distortion_category = NULL, f_e_hf = NULL, f_e_lf = NULL, abg_fraction = 0.75, ...) {

  dots <- list(...)
  anchor <- if (!is.null(dots$anchor)) dots$anchor else 0.46
  slope_trigger <- if (!is.null(dots$slope_trigger)) dots$slope_trigger else 15
  bypass_pta <- if (!is.null(dots$bypass_pta)) dots$bypass_pta else 70
  rs_floor <- if (!is.null(dots$rs_floor)) dots$rs_floor else -10
  # Defensive guard: ensure module is always a valid length-1 string
  if (is.null(module) || length(module) == 0 || !nzchar(module)) module <- "standard"
  
  # 0. Conductive Component Separation
  if (is.null(loss)) {
    loss <- rep(0, length(threshold))
  }
  sn_threshold <- pmax(0, threshold - loss)


  # 0. Minimal Hearing Loss (MHL) Module Bypass
  # If the patient has near-normal hearing (PTA <= 25) and selects the MHL module,
  # we completely bypass the standard WDRC compensation formula.
  pta_4 <- mean(sn_threshold[freq %in% c(500, 1000, 2000, 4000)], na.rm = TRUE)
  if (module == "mhl" && !is.na(pta_4) && pta_4 <= 25) {
    # MHL applies a flat 3-5 dB insertion gain above 1kHz to access SNR features,
    # tapering strictly to 0 dB in the low frequencies.
    mhl_gain <- approx(x = c(250, 500, 1000, 2000, 4000, 8000), 
                       y = c(0, 0, 3, 5, 5, 5), xout = freq, rule = 2)$y
                       
    # Linear amplification for speech inputs up to 65 dB SPL
    data("critical", envir = environment())
    pivot <- approx(x = log10(critical$fi), y = critical$normal, xout = log10(freq), rule = 2)$y
    ct_band <- pivot + 5 # Set CT slightly above normal speech
    
    g_ct <- mhl_gain
    # Derive base CR from sensorineural loss (normal hearing requires CR ~ 1.0)
    cr_loud <- 1 + pmax(0, sn_threshold - 20) / 40
    
    band_input <- pivot + (input_level - 65)
    ig <- ifelse(band_input <= ct_band,
                 g_ct,
                 g_ct - (band_input - ct_band) * (1 - 1/cr_loud))
                 

    
    return(ig)
  }
  
  # 1. Base Anchor (For 65 dB SPL input)
  # We use a frequency-specific half-gain anchor decoupled from broadband PTA, similar to NAL-NL2 and DSL.
  # This prevents normal low-frequency hearing from artificially dragging down high-frequency gain.
  c_freqs <- c(250, 500, 1000, 2000, 3000, 4000, 6000, 8000)
  
  # Base WDRC Anchor according to manuscript
  base_mult <- anchor
  
  # Standard experienced user baseline (from manuscript)
  c_vals <- c(-8, -1, 3, 1, 0, 0, 0, 0)
  
  if (experience == "new") {
    # New Users receive a purely nominal, uncalibrated flat -3 dB reduction
    c_vals <- c_vals - 3
  } else if (experience == "power") {
    # Power Users (not defined in manuscript, but present in UI) get +3 dB
    c_vals <- c_vals + 3
  }
  
  c_interp <- approx(x = log10(c_freqs), y = c_vals, xout = log10(freq), rule = 2)$y
  
  # Scale c_interp by the proportion of sensorineural loss. Pure conductive losses 
  # have normal equal-loudness contours, so they do not need the LTASS reshaping penalty.
  sn_ratio <- ifelse(threshold > 0, sn_threshold / threshold, 0)
  c_interp <- c_interp * sn_ratio
  # 1a. Reverse Slope Correction
  # For reverse slope losses (where low frequencies are significantly worse than high frequencies),
  # attempting to fully restore low/mid-frequency audibility causes severe upward spread of masking,
  # where low-frequency amplification (e.g. vowels, ambient noise) masks the normal high-frequency consonants.
  # 1b. NAL-NL2 Reverse-Slope Adjustment (Johnson 2011, Eq. 3 & 4)
  # If the loss is severely reverse-sloping, NAL-NL2 limits low-frequency gain
  # We use sensorineural threshold since conductive components do not cause upward spread of masking.
  low_thresh_mean  <- mean(sn_threshold[freq <= 500])
  high_thresh_mean <- mean(sn_threshold[freq >= 2000])
  
  rs_diff <- pmax(0, low_thresh_mean - high_thresh_mean)
  if (rs_diff > slope_trigger) {
    rs_factor <- pmax(0, pmin(1, (rs_diff - slope_trigger) / 20))
    
    # Log-linear taper applied to frequencies below 1000 Hz
    w_lf <- pmax(0, pmin(1, 1 - (log10(freq) - log10(250)) / log10(1000/250))) 
    
    # Eq. 7: Apply the -10 dB floor ONLY to the low frequencies via W_LF taper
    c_interp <- c_interp * (1 - rs_factor * w_lf) + (rs_floor * rs_factor * w_lf)
  }
  
  # Base multiplier is 0.46 (from manuscript, defined above)
  
  # Slope Penalty: If there is a massive difference between high and low thresholds,
  # standard half-gain will cause too much gain in the low frequencies (upward spread of masking)
  # and heavily penalize gain there to prevent the low/mid frequencies from masking the highs.
  # We also boost the high frequencies to pull them out of the slope.
  steep_slope_diff <- pmax(0, high_thresh_mean - low_thresh_mean)
  
  # 1c. Explicit Dead Region Edge Frequencies
  # We no longer automatically infer dead regions from thresholds alone, as this is clinically invalid.
  # Defaults to Inf / -Inf if not explicitly provided, bypassing the dead region roll-offs.
  f_e_hf <- if (!is.null(f_e_hf)) f_e_hf else Inf
  f_e_lf <- if (!is.null(f_e_lf)) f_e_lf else -Inf
  
  disable_sdlfp <- if (!is.null(dots$disable_sdlfp)) dots$disable_sdlfp else FALSE
  if (steep_slope_diff > slope_trigger && !disable_sdlfp) {
    # Profound High-Frequency Bypass (PF_bypass) - Eq. 3
    # If high frequencies are extremely severe (>70 dB HL), we bypass the low frequency penalty 
    # so they can still hear low frequency cues.
    pf_bypass <- pmax(0, pmin(1, (95 - high_thresh_mean) / 25))
    
    # Scale the penalty over a 20 dB window, cap at 15 dB (Eq. 4)
    steep_factor <- pmax(0, pmin(1, (steep_slope_diff - slope_trigger) / 20))
    lf_penalty <- steep_factor * slope_trigger * pf_bypass
    
    # Log-linear taper applied to frequencies below 1000 Hz
    lf_weight <- pmax(0, pmin(1, 1 - (log10(freq) - log10(250)) / log10(1000/250))) 
    
    c_interp <- c_interp - (lf_penalty * lf_weight)
  }

  
  # Use the dynamically selected base multiplier based on user experience.
  # This globally sets the 65 dB SPL anchor to match user comfort vs audibility needs.
  # We DO NOT bound this to 0 here because normal hearing needs negative insertion gain to shape the response!
  g_65 <- base_mult * sn_threshold + c_interp
  
  # Severe-Loss Booster (Section II.F)
  # Bounded severe-loss booster (slope = 0.15) applied to thresholds.
  # The manuscript defaults to off, but Eq. 1 uses the 60 dB HL aggressive onset.
  # We apply the 0.15 slope and remove the undocumented tapers (dead region & mid-taper).
  b_en <- 1.0 # Set to 1.0 to enable the aggressive ablation mode from Eq. 1
  slb_final <- b_en * 0.15 * pmax(0, pmin(80, sn_threshold) - 60)
  
  g_65 <- g_65 + slb_final
  
  # High-Frequency Desensitization Roll-off (Smooth Soft-Compression)
  # Instead of a harsh penalty or hard cap (which causes jagged artifacts),
  # we softly compress any insertion gain that exceeds 25 dB in the high frequencies.
  
  # Determine if there is a sloping component (difference between high and low thresholds)
  best_low_thresh <- min(sn_threshold[freq <= 1000], na.rm = TRUE)
  # The slope factor measures how far above the best low threshold a particular frequency is
  # It scales from 0 (if within 25 dB) up to 1 (if > 45 dB worse than low frequencies).
  slope_factor <- pmax(0, pmin(1, (sn_threshold - best_low_thresh - 25) / 20))
  
  # Weighting factor that fades in from 2000 Hz to 4000 Hz
  hf_weight <- pmax(0, pmin(1, (freq - 2000) / 2000)) 
  
  # We dynamically scale the gain limit so severe losses can still get the amplification they need.
  # Lifted base limit from 25 to 30 dB to prevent underamplification of steep slopes
  if (experience == "power") {
    gain_limit <- 40 + pmax(0, sn_threshold - 60) * 0.5
  } else {
    gain_limit <- 30 + pmax(0, sn_threshold - 60) * 0.4
  }
  
  if (!is.null(distortion_category) && distortion_category %in% c("Moderate", "High")) {
    gain_limit <- gain_limit - 10 # Increase soft compression significantly for distorted ears
  }
  
  excess_gain <- pmax(0, g_65 - gain_limit)
  
  # Compress the excess (2:1 ratio instead of 4:1 to allow more gain through)
  compressed_excess <- excess_gain * 0.50 
  
  # Apply the smooth compression only to the high frequencies of a sloping loss
  g_65 <- g_65 - (hf_weight * slope_factor * (excess_gain - compressed_excess))
  
  # 1.5 Dynamic Range Mapping (DSL v5.0 philosophy)
  # Compare measured LDL to expected LDL
  predicted_ldl_spl <- 100 + pmax(0, sn_threshold - 40) * 0.5 + loss
  
  if (!is.null(ldl) && length(ldl) == length(sn_threshold)) {
    measured_ldl_spl <- ifelse(is.na(ldl), predicted_ldl_spl, ldl + 10)
    ldl_diff <- measured_ldl_spl - predicted_ldl_spl
  } else {
    ldl_diff <- 0
  }
  
  # Decrease gain if LDL is lower than expected (dynamic range is squeezed)
  # 0.2 dB gain reduction for every 1 dB the LDL is lowered
  g_65 <- g_65 + (ldl_diff * 0.2)
  
  # 1e. NAL-NL3 Bandwidth Roll-off
  # Reduced emphasis on using low-frequency (<= 250 Hz) and very high-frequency (>= 6 kHz) gain
  bw_rolloff <- approx(x = c(250, 500, 1000, 2000, 4000, 6000, 8000), 
                       y = c(0.7, 1.0, 1.0, 1.0, 1.0, 0.8, 0.5), xout = freq, rule = 2)$y
  g_65 <- g_65 * bw_rolloff
  
  
  # 1d. Cochlear Dead Region Roll-off
  # Based on Moore (2001, 2004) and Vickers et al. (2001).
  # If a dead region is detected, amplifying beyond its viable boundary provides no speech 
  # intelligibility benefit and causes distortion/feedback.
  
  # Margolis et al. (2025) Distortion Categorization Roll-off
  if (!is.null(distortion_category)) {
    if (distortion_category == "Moderate") {
      # Roll off -5 dB per octave above 2000 Hz
      dist_roll <- pmax(0, log2(freq / 2000)) * 5
      g_65 <- g_65 - dist_roll
    } else if (distortion_category == "High") {
      # Roll off -10 dB per octave above 1500 Hz
      dist_roll <- pmax(0, log2(freq / 1500)) * 10
      g_65 <- g_65 - dist_roll
    }
  }
  
  # High-Frequency Dead Region (HFDR)
  if (is.finite(f_e_hf)) {
    if (steep_slope_diff > 30) {
      # If the slope is steep, start the roll-off slightly before the dead region boundary (0.85x) 
      # and apply a technically feasible acoustic roll-off (30 dB/oct) to prevent severe phase distortion.
      hf_cutoff <- 0.85 * f_e_hf
      hf_dr_penalty <- pmax(0, log2(freq / hf_cutoff)) * 30 # Graceful, physically achievable roll-off
    } else {
      # Otherwise, we use Moore's 1.7x basal spread allowance.
      hf_cutoff <- 1.7 * f_e_hf
      hf_dr_penalty <- pmax(0, log2(freq / hf_cutoff)) * 30
    }
    
    g_65 <- g_65 - hf_dr_penalty
  }
  
  # Low-Frequency Dead Region (LFDR)
  if (is.finite(f_e_lf)) {
    lf_cutoff <- 0.57 * f_e_lf
    
    # Apply a steep penalty of 30 dB per octave below the cutoff
    lf_dr_penalty <- pmax(0, log2(lf_cutoff / freq)) * 30
    g_65 <- g_65 - lf_dr_penalty
  }
  
  # 2. Multi-channel WDRC Pivot
  # We pivot WDRC around the expected band level for normal speech (65 dB overall)
  data("critical", envir = environment())
  pivot <- approx(x = log10(critical$fi), y = critical$normal, xout = log10(freq), rule = 2)$y
  

  # 3. Bi-directional Dynamic Range Optimization (WDRC)
  # To match evidence-based targets (e.g., Keidser et al., NAL-NL2), we scale the Compression Ratio (CR).
  
  # Moderate (40-65 HL):
  # To preserve the speech envelope and avoid SNR degradation at high levels (Hornsby & Ricketts, 2001),
  # we scale the CR conservatively.
  base_cr <- 1 + pmax(0, sn_threshold - 20) / 40
  
  # Adjust base_cr based on LDL discrepancy
  # A lower LDL (squeezed dynamic range) requires a higher compression ratio
  base_cr <- base_cr - (ldl_diff * 0.02)
  
  # Severe/Profound (>65 HL): CR actually REDUCES back toward linear.
  # Patients with severe loss prefer lower compression (1:1 to 2:1) to preserve the temporal envelope.
  severe_penalty <- pmax(0, sn_threshold - 65) / 30
  
  # Frequency dependence for severe loss:
  # Low frequencies (<1000 Hz) strongly prefer linear (CR ~1.0).
  # High frequencies can tolerate more compression (CR ~1.5 - 2.0).
  freq_modifier <- pmax(0, pmin(1, (freq - 500) / 2500)) # 0 at 500Hz, 1 at >=3000Hz
  adjusted_penalty <- severe_penalty * (1.5 - 0.5 * freq_modifier)
  
  cr_loud <- base_cr - adjusted_penalty
  
  # Clinical Limits: Ensure CR stays between 1.0 (linear) and a strict frequency-dependent maximum.
  # Based on Keidser et al. (2007) and Hornsby & Ricketts (2001), low frequencies are strictly capped at 1.5,
  # while high frequencies can tolerate slightly more (up to 2.4).
  max_cr <- 1.5 + (0.9 * freq_modifier) # 1.5 at <=500Hz, 2.4 at >=3000Hz
  
  # Grant & Walden (2013) Age-based Compression Relaxing
  # Older adults (>60 years) have significantly poorer temporal resolution (gap detection).
  # We reduce the max_cr progressively toward 1.5 to preserve the temporal speech envelope.
  if (!is.null(age_years) && age == "adult" && age_years > 60) {
    age_factor <- pmin(1.0, (age_years - 60) / 30.0) # Scales from 0 at age 60 to 1 at age 90
    max_cr <- max_cr - (max_cr - 1.5) * age_factor
  }
  
  cr_loud <- pmax(1.0, pmin(cr_loud, max_cr))
  
  # 3.5 Variable Compression Threshold (CT)
  # Standardizing CT to be universally lower (30-45 dB SPL) to match NAL-NL2 and DSL.
  # A lower CT ensures WDRC kicks in earlier, applying more gain to soft speech (55 dB SPL).
  # This restores audibility for soft sounds and dramatically reduces listening effort,
  # especially when using our lower-gain comfort multipliers (e.g. 0.40 / 0.45).
  if (experience == "power") {
    ct_overall <- approx(x = c(20, 50, 80, 100), y = c(25, 30, 35, 40), xout = sn_threshold, rule = 2)$y
  } else {
    ct_overall <- approx(x = c(20, 50, 80, 100), y = c(30, 35, 40, 45), xout = sn_threshold, rule = 2)$y
  }
  
  # ct_overall is the OVERALL speech level CT. We must convert it to a BAND level CT.
  # Since 'pivot' is the band level for 65 dB SPL overall speech, 
  # the band level CT is simply pivot + (ct_overall - 65).
  ct_band <- pivot + (ct_overall - 65)
  
  # 3.7 Aggressive MPO Defense (Collision Prevention)
  # For steeply sloping losses with low LDLs, loud speech peaks run a high risk of 
  # striking the MPO limit, causing severe distortion. We aggressively lower the CT 
  # and increase the CR to squash the peaks.
  if (!is.null(ldl) && length(ldl) == length(sn_threshold)) {
    measured_ldl_spl <- ifelse(is.na(ldl), predicted_ldl_spl, ldl + 10)
    low_ldl_mask <- measured_ldl_spl < 100
    
    if (steep_slope_diff > 15 && any(low_ldl_mask)) {
      ldl_penalty <- pmax(0, 100 - measured_ldl_spl)
      
      # Drop CT by up to 10 dB, engaging compression earlier
      ct_band <- ct_band - ldl_penalty
      
      # Forcefully increase the CR in the danger zones
      cr_loud[low_ldl_mask] <- cr_loud[low_ldl_mask] + (ldl_penalty[low_ldl_mask] * 0.05)
    }
  }
  
  # 3.8 Comfort in Noise (CIN) Module
  if (module == "cin") {
    # Comfort in Noise (CIN) module aims to reduce loudness and improve comfort
    # in high-level noise environments. Based on evidence, listeners prefer
    # less compression (linear or 1.5:1) when noise exceeds the compression threshold.
    cr_loud <- pmin(cr_loud, 1.5)
    
    # We lower the WDRC pivot / CT so compression kicks in earlier.
    ct_band <- ct_band - 10
  }
  
  # The target gain 'g_65' is prescribed for an input level of 'pivot'.
  # If CT > pivot, the pivot is in the linear region, so the linear gain is simply g_65.
  # If CT <= pivot, the pivot is in the WDRC region. We calculate the gain at CT by climbing the WDRC slope backward.
  g_ct <- ifelse(ct_band > pivot,
                 g_65, 
                 g_65 + (pivot - ct_band) * (1 - 1/cr_loud))
  
  # 4. Multistage I/O calculation (Linear below CT, WDRC above CT)
  # input_level is the OVERALL speech level (e.g. 50, 65, 80). 
  # We must convert it to the BAND level by referencing the pivot (which is the band level at 65 dB overall).
  band_input <- pivot + (input_level - 65)
  
  ig <- ifelse(band_input <= ct_band,
               g_ct,
               g_ct - (band_input - ct_band) * (1 - 1/cr_loud))
               

  
  # 6. Apply Empirical Demographic Adjustments (Keidser et al., 2012)
  adjustment <- 0
  
  # Gender: Females prefer ~1.5 dB less gain
  if (gender == "female") {
    adjustment <- adjustment - 1.5
  }
  # Configuration: Unilateral fittings require ~3 dB more gain due to lack of binaural summation
  if (config == "unilateral") {
    adjustment <- adjustment + 3.0
  }
  
  # Experience: New users with PTA > 40 prefer less gain (up to 6 dB less)
  if (experience == "new") {
    pta_val <- mean(sn_threshold[freq %in% c(500, 1000, 2000)], na.rm = TRUE)
    if (!is.na(pta_val) && pta_val > 40) {
      exp_penalty <- (pta_val - 40) * 0.3
      exp_penalty <- pmin(exp_penalty, 6.0)
      adjustment <- adjustment - exp_penalty
    }
  }
  
  ig <- ig + adjustment
  
  # 7. Apply Acoustic Coupling / Vent Effect (Caporali et al., 2019)
  # Simulated Real-Ear Aided Response (REAR) by subtracting leakage.
  if (coupling != "custom_occluded") {
    ve_freqs <- c(250, 500, 1000, 2000, 4000, 8000)
    if (coupling == "open_dome") {
      ve_loss <- c(-35, -28, -15, -2, 0, 0)
    } else if (coupling == "tulip_dome") {
      ve_loss <- c(-25, -18, -5, 0, 0, 0)
    } else if (coupling == "double_dome") {
      ve_loss <- c(-20, -10, 0, 0, 0, 0)
    } else if (coupling == "vent_1mm_solid") {
      ve_loss <- c(-3, -1, 0, 0, 0, 0)
    } else if (coupling == "vent_2mm_solid") {
      ve_loss <- c(-8, -2, 0, 0, 0, 0)
    } else if (coupling == "vent_3mm_solid") {
      ve_loss <- c(-12, -4, 0, 0, 0, 0)
    } else if (coupling == "vent_1mm_hollow") {
      ve_loss <- c(-12, -3, 0, 0, 0, 0)
    } else if (coupling == "vent_2mm_hollow") {
      ve_loss <- c(-22, -12, -5, -2, 0, 0)
    } else if (coupling == "vent_3mm_hollow") {
      ve_loss <- c(-25, -15, -8, -4, 0, 0)
    } else {
      ve_loss <- c(0, 0, 0, 0, 0, 0)
    }
    
    ve_interp <- approx(x = log10(ve_freqs), y = ve_loss, xout = log10(freq), rule = 2)$y
    ig <- ig + ve_interp
  } else {
    ve_interp <- rep(0, length(freq))
  }
  
  # 8. Conductive Component Correction
  # Restore the Air-Bone Gap as linear gain. Since conductive loss acts as a 
  # pre-cochlear attenuator (earplug), upward spread of masking does not apply,
  # and we can fully restore the loss without penalty (abg_fraction = 1.0).
  abg_gain <- abg_fraction * loss
  
  ig <- ig + abg_gain
  
  # Ensure the target insertion gain doesn't demand impossible active noise cancellation.
  # We floor the target at slightly below the physical insertion loss of the vent/coupling.
  # A hard floor at 0 dB forces the hearing aid to fight open vents, causing massive comb filtering.
  ig <- pmax(ig, ve_interp - 10, na.rm = TRUE)
  return(ig)
}

calculate_nal_sspl90 <- function(threshold, gain, ldl = NULL, age = "adult", age_months = NULL, loss = NULL, freq = c(250, 500, 1000, 2000, 4000, 8000)) {
  # 0. Separate Sensorineural component
  if (is.null(loss)) {
    loss <- rep(0, length(threshold))
  }
  sn_threshold <- pmax(0, threshold - loss)

  # NAL SSPL90 rule (Maximum Power Output)
  # Derived from Dillon (2012) and NAL guidelines for avoiding discomfort
  
  # 1. Base SSPL90 for normal hearing is roughly 90-100 dB SPL (we use 105 to provide headroom)
  # 2. SSPL90 increases by roughly 0.5 dB for every 1 dB of sensorineural hearing loss above 20 dB HL
  # 3. Add conductive loss linearly since it attenuates the entire signal reaching the cochlea.
  heuristic_mpo <- 105 + pmax(0, sn_threshold - 20) * 0.5 + loss
  
  # 4. Impose Realistic Receiver Limits
  # As established, modern RICs max out around 118-124 dB SPL. We cap the MPO at 120 dB SPL to avoid prescribing gain 
  # that would force the hearing aid into severe saturation distortion.
  heuristic_mpo <- pmin(heuristic_mpo, 120)
  
  # 5. Estimated LDL & Safety Margin
  # Estimated LDLs often range around 100 dB SPL for normal hearing, 
  # expanding up to 130-140 dB SPL for profound loss. 
  estimated_ldl_spl <- 105 + pmax(0, sn_threshold - 20) * 0.5 + loss
  
  if (!is.null(ldl) && length(ldl) == length(threshold)) {
    # If explicit LDL is provided (in HL), convert to approximate SPL (HL + 10)
    # Use estimated LDL if measured LDL is NA
    ldl_spl <- ifelse(is.na(ldl), estimated_ldl_spl, ldl + 10)
  } else {
    ldl_spl <- estimated_ldl_spl
  }
  
  # We apply the 5 dB safety margin from the heuristics to account for real-ear SPL variations:
  safe_mpo <- ldl_spl - 5
  
  # Final MPO: Take the heuristic MPO, but cap it at the Safe MPO limit.
  mpo <- pmin(heuristic_mpo, safe_mpo)
  
  # Absolute ceiling (Johnson 2017 PTS Safety Limits)
  # Limit output based on threshold to avoid permanent threshold shift.
  pts_safe_limit <- 105 + pmax(0, sn_threshold - 50) * 0.5 + loss
  mpo <- pmin(mpo, pts_safe_limit)
  
  # ABSOLUTE CLINICAL HARD CAP: NEVER exceed 120 dB SPL (at the cochlea)
  max_cochlear <- 120 + loss
  mpo <- pmin(mpo, max_cochlear)
  
  # ABSOLUTE HARDWARE LIMIT: Acoustic hearing aids max out around 135 dB SPL.
  mpo <- pmin(mpo, 135)
  
  return(mpo)
}

#' Prescribe Compression Settings based on Hearing Loss
#'
#' @description
#' Provides recommendations for compression speed (fast vs slow acting) and
#' compression ratio/release times based on the four-frequency Pure Tone Average (PTA4).
#'
#' @param freq A numeric vector of frequencies.
#' @param threshold A numeric vector of hearing thresholds.
#' @param module The operating module ("standard", "cin", "mhl").
#' @return A list containing compression recommendations.
#' @export
prescribe_compression <- function(freq, threshold, module = "standard") {
  # Calculate 4-frequency Pure Tone Average (PTA4)
  pta_freqs <- c(500, 1000, 2000, 4000)
  if (all(pta_freqs %in% freq)) {
    pta_thresh <- threshold[match(pta_freqs, freq)]
  } else {
    pta_thresh <- approx(x = log10(freq), y = threshold, xout = log10(pta_freqs), rule = 2)$y
  }
  pta4 <- mean(pta_thresh, na.rm = TRUE)
  
  if (pta4 >= 35) {
    speed <- "Slow-acting"
    release_time <- "> 500 ms"
    speed_reason <- sprintf("PTA4 \u2265 35 dB HL (%.1f dB HL)", pta4)
  } else {
    speed <- "Fast-acting"
    release_time <- "< 200 ms"
    speed_reason <- sprintf("PTA4 < 35 dB HL (%.1f dB HL)", pta4)
  }
  
  ratio_note <- "Target \u2264 2:1. If CR \u2265 3:1 is necessary, use longer release time (e.g., 1000 ms) to preserve clarity."
  if (module == "cin") {
    ratio_note <- "Comfort in Noise (CIN): Prescribing lower compression (linear to 1.5:1) for high-level noise."
  }
  
  return(list(
    pta4 = pta4,
    speed = speed,
    release_time = release_time,
    speed_reason = speed_reason,
    ratio_note = ratio_note
  ))
}

Try the SII package in your browser

Any scripts or data that you put into this service are public.

SII documentation built on Sept. 12, 2026, 5:08 p.m.