calibrator.py 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145
  1. """
  2. Calibrator — Auto-fits EQ bands to compensate for measured frequency response.
  3. """
  4. import numpy as np
  5. from scipy.optimize import minimize
  6. from typing import List, Tuple, Dict
  7. from .eq_engine import EQBand, EQEngine
  8. TAPE_PROFILES = {
  9. "Type I (Ferric)": {"low_cut": 50.0, "high_cut": 15000.0, "eq_us": 120},
  10. "Type II (Chrome)": {"low_cut": 45.0, "high_cut": 16000.0, "eq_us": 70},
  11. "Type IV (Metal)": {"low_cut": 40.0, "high_cut": 17000.0, "eq_us": 70},
  12. }
  13. def calculate_correction_curve(response_db: np.ndarray, max_correction: float = 15.0) -> np.ndarray:
  14. """
  15. Invert the measured response to get the correction curve.
  16. Limits the maximum boost/cut to prevent extreme EQ values.
  17. """
  18. # Invert the response (if tape cuts 5dB, we boost 5dB)
  19. correction = -response_db
  20. # Normalize to 0dB mean around 1kHz (typical reference)
  21. # Assume response_db is already somewhat normalized, or just mean shift
  22. mean_level = np.mean(correction)
  23. correction -= mean_level
  24. # Clip to max correction
  25. correction = np.clip(correction, -max_correction, max_correction)
  26. return correction
  27. def _evaluate_eq_curve(freqs: np.ndarray, bands: List[EQBand], sample_rate: int) -> np.ndarray:
  28. """
  29. Calculate the combined frequency response of the EQ bands.
  30. """
  31. # Create temporary EQ engine to calculate response
  32. engine = EQEngine(sample_rate=sample_rate)
  33. engine.bands = bands
  34. engine.update_filters()
  35. # If no filters, return flat response
  36. if engine._sos is None:
  37. return np.zeros_like(freqs)
  38. # Calculate frequency response using sosfreqz
  39. # freqs normalized to nyquist
  40. w = 2 * np.pi * freqs / sample_rate
  41. from scipy.signal import sosfreqz
  42. _, h = sosfreqz(engine._sos, worN=w)
  43. return 20 * np.log10(np.abs(h) + 1e-10)
  44. def fit_eq_bands(
  45. freqs: np.ndarray,
  46. correction_curve: np.ndarray,
  47. num_peaking_bands: int = 10,
  48. sample_rate: int = 44100,
  49. tape_type: str = "Type I (Ferric)"
  50. ) -> List[EQBand]:
  51. """
  52. Fit parametric EQ bands to the target correction curve, constrained by tape type.
  53. """
  54. profile = TAPE_PROFILES.get(tape_type, TAPE_PROFILES["Type I (Ferric)"])
  55. low_cut = profile["low_cut"]
  56. high_cut = profile["high_cut"]
  57. # Initialize bands
  58. bands = []
  59. # Instead of shelves that fight the natural roll-off, use peaking bands
  60. # distributed strictly within the usable frequency range of the tape.
  61. # We add 2 extra bands instead of the shelves to give 12 peaking bands total.
  62. total_bands = num_peaking_bands + 2
  63. # Log-spaced center frequencies
  64. f_centers = np.logspace(np.log10(low_cut * 1.5), np.log10(high_cut * 0.8), total_bands)
  65. for f in f_centers:
  66. bands.append(EQBand(freq=float(f), gain=0.0, q=1.414, filter_type='peaking'))
  67. # Optimization target function
  68. def error_func(params):
  69. # params is a flattened array of [gain, q] for each band
  70. # Keep frequencies fixed for stability
  71. temp_bands = []
  72. for i, b in enumerate(bands):
  73. gain = params[i*2]
  74. q = max(0.1, min(10.0, params[i*2+1])) # Constrain Q
  75. temp_bands.append(EQBand(
  76. freq=b.freq,
  77. gain=gain,
  78. q=q,
  79. filter_type=b.filter_type
  80. ))
  81. eq_curve = _evaluate_eq_curve(freqs, temp_bands, sample_rate)
  82. # Weighted error: ignore extremes outside tape limits, focus on mids
  83. weights = np.zeros_like(freqs)
  84. # Weight 1.0 for usable range
  85. valid_mask = (freqs >= low_cut) & (freqs <= high_cut)
  86. weights[valid_mask] = 1.0
  87. # Extra weight for critical mid-range (300Hz - 5kHz)
  88. mid_mask = (freqs >= 300) & (freqs <= 5000)
  89. weights[mid_mask] = 2.0
  90. # Mean squared error
  91. mse = np.average((eq_curve - correction_curve)**2, weights=weights)
  92. return mse
  93. # Initial guess: flat 0dB, default Q
  94. initial_guess = np.zeros(len(bands) * 2)
  95. for i in range(len(bands)):
  96. initial_guess[i*2+1] = 1.414 if bands[i].filter_type == 'peaking' else 0.707
  97. # Bounds: Gain [-15, 15], Q [0.1, 10]
  98. bounds = []
  99. for _ in range(len(bands)):
  100. bounds.append((-15.0, 15.0)) # Gain
  101. bounds.append((0.1, 10.0)) # Q
  102. # Run optimization
  103. result = minimize(
  104. error_func,
  105. initial_guess,
  106. bounds=bounds,
  107. method='L-BFGS-B',
  108. options={'maxiter': 100}
  109. )
  110. # Apply optimized parameters
  111. optimized_bands = []
  112. for i, b in enumerate(bands):
  113. gain = float(result.x[i*2])
  114. q = float(result.x[i*2+1])
  115. optimized_bands.append(EQBand(
  116. freq=b.freq,
  117. gain=gain,
  118. q=q,
  119. filter_type=b.filter_type
  120. ))
  121. return optimized_bands