eq_engine.py 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175
  1. """
  2. Parametric EQ Engine — Applies biquad filters to audio streams.
  3. """
  4. import numpy as np
  5. import scipy.signal as signal
  6. from dataclasses import dataclass
  7. from typing import List, Optional
  8. @dataclass
  9. class EQBand:
  10. """Parameters for a single EQ band."""
  11. freq: float # Center frequency in Hz
  12. gain: float # Gain in dB (not used for HP/LP)
  13. q: float # Quality factor
  14. filter_type: str # 'peaking', 'lowshelf', 'highshelf', 'highpass', 'lowpass'
  15. enabled: bool = True
  16. class EQEngine:
  17. """
  18. Processes audio through a chain of parametric EQ bands.
  19. Uses Second-Order Sections (SOS) for numerical stability.
  20. """
  21. def __init__(self, sample_rate: int = 44100):
  22. self.sample_rate = sample_rate
  23. self.bands: List[EQBand] = []
  24. self._sos = None
  25. self._zi = None # Filter state for continuous processing
  26. # Default initialization: 1 low shelf, 10 peaking, 1 high shelf
  27. self.reset_to_default_bands()
  28. def reset_to_default_bands(self):
  29. """Setup a default 12-band EQ."""
  30. self.bands = [
  31. EQBand(freq=100.0, gain=0.0, q=0.707, filter_type='lowshelf'),
  32. EQBand(freq=250.0, gain=0.0, q=1.414, filter_type='peaking'),
  33. EQBand(freq=500.0, gain=0.0, q=1.414, filter_type='peaking'),
  34. EQBand(freq=1000.0, gain=0.0, q=1.414, filter_type='peaking'),
  35. EQBand(freq=2000.0, gain=0.0, q=1.414, filter_type='peaking'),
  36. EQBand(freq=4000.0, gain=0.0, q=1.414, filter_type='peaking'),
  37. EQBand(freq=8000.0, gain=0.0, q=1.414, filter_type='peaking'),
  38. EQBand(freq=12000.0, gain=0.0, q=0.707, filter_type='highshelf'),
  39. ]
  40. self.update_filters()
  41. def _get_biquad_coeffs(self, band: EQBand) -> tuple:
  42. """Calculate biquad coefficients based on Audio EQ Cookbook."""
  43. A = 10.0 ** (band.gain / 40.0)
  44. w0 = 2.0 * np.pi * band.freq / self.sample_rate
  45. alpha = np.sin(w0) / (2.0 * band.q)
  46. if band.filter_type == 'peaking':
  47. b0 = 1.0 + alpha * A
  48. b1 = -2.0 * np.cos(w0)
  49. b2 = 1.0 - alpha * A
  50. a0 = 1.0 + alpha / A
  51. a1 = -2.0 * np.cos(w0)
  52. a2 = 1.0 - alpha / A
  53. elif band.filter_type == 'lowshelf':
  54. sqrtA = np.sqrt(A)
  55. b0 = A * ((A + 1.0) - (A - 1.0) * np.cos(w0) + 2.0 * sqrtA * alpha)
  56. b1 = 2.0 * A * ((A - 1.0) - (A + 1.0) * np.cos(w0))
  57. b2 = A * ((A + 1.0) - (A - 1.0) * np.cos(w0) - 2.0 * sqrtA * alpha)
  58. a0 = (A + 1.0) + (A - 1.0) * np.cos(w0) + 2.0 * sqrtA * alpha
  59. a1 = -2.0 * ((A - 1.0) + (A + 1.0) * np.cos(w0))
  60. a2 = (A + 1.0) + (A - 1.0) * np.cos(w0) - 2.0 * sqrtA * alpha
  61. elif band.filter_type == 'highshelf':
  62. sqrtA = np.sqrt(A)
  63. b0 = A * ((A + 1.0) + (A - 1.0) * np.cos(w0) + 2.0 * sqrtA * alpha)
  64. b1 = -2.0 * A * ((A - 1.0) + (A + 1.0) * np.cos(w0))
  65. b2 = A * ((A + 1.0) + (A - 1.0) * np.cos(w0) - 2.0 * sqrtA * alpha)
  66. a0 = (A + 1.0) - (A - 1.0) * np.cos(w0) + 2.0 * sqrtA * alpha
  67. a1 = 2.0 * ((A - 1.0) - (A + 1.0) * np.cos(w0))
  68. a2 = (A + 1.0) - (A - 1.0) * np.cos(w0) - 2.0 * sqrtA * alpha
  69. elif band.filter_type == 'highpass':
  70. b0 = (1.0 + np.cos(w0)) / 2.0
  71. b1 = -(1.0 + np.cos(w0))
  72. b2 = (1.0 + np.cos(w0)) / 2.0
  73. a0 = 1.0 + alpha
  74. a1 = -2.0 * np.cos(w0)
  75. a2 = 1.0 - alpha
  76. elif band.filter_type == 'lowpass':
  77. b0 = (1.0 - np.cos(w0)) / 2.0
  78. b1 = 1.0 - np.cos(w0)
  79. b2 = (1.0 - np.cos(w0)) / 2.0
  80. a0 = 1.0 + alpha
  81. a1 = -2.0 * np.cos(w0)
  82. a2 = 1.0 - alpha
  83. else:
  84. raise ValueError(f"Unknown filter type: {band.filter_type}")
  85. return np.array([b0, b1, b2, a0, a1, a2])
  86. def update_filters(self):
  87. """Recompute SOS coefficients from all enabled bands."""
  88. sos_list = []
  89. for band in self.bands:
  90. if band.enabled:
  91. coeffs = self._get_biquad_coeffs(band)
  92. # Normalize by a0
  93. a0 = coeffs[3]
  94. sos_section = np.array([coeffs[0]/a0, coeffs[1]/a0, coeffs[2]/a0,
  95. 1.0, coeffs[4]/a0, coeffs[5]/a0])
  96. sos_list.append(sos_section)
  97. if not sos_list:
  98. self._sos = None
  99. self._zi = None
  100. else:
  101. self._sos = np.vstack(sos_list)
  102. # Reset zi to match the new filter order (number of sections)
  103. # zi shape needs to handle stereo if needed, but for now we'll
  104. # initialize it on the first process() call based on input shape.
  105. self._zi = None
  106. def set_sample_rate(self, sr: int):
  107. if self.sample_rate != sr:
  108. self.sample_rate = sr
  109. self.update_filters()
  110. def process(self, chunk: np.ndarray) -> np.ndarray:
  111. """
  112. Process a chunk of audio data.
  113. chunk: shape (N_samples, channels)
  114. """
  115. if self._sos is None:
  116. return chunk
  117. # Initialize or reshape filter state zi if needed
  118. n_sections = self._sos.shape[0]
  119. n_channels = chunk.shape[1] if chunk.ndim > 1 else 1
  120. if self._zi is None or self._zi.shape[2] != n_channels or self._zi.shape[0] != n_sections:
  121. # zi needs shape (n_sections, 2, n_channels) for sosfilt
  122. self._zi = np.zeros((n_sections, 2, n_channels))
  123. # Apply filter and update state
  124. out, self._zi = signal.sosfilt(self._sos, chunk, axis=0, zi=self._zi)
  125. return out.astype(np.float32)
  126. def reset_state(self):
  127. """Clear filter state (call when stopping/starting playback to avoid glitches)."""
  128. self._zi = None
  129. def save_bands(self, filepath: str):
  130. import json
  131. data = []
  132. for b in self.bands:
  133. data.append({
  134. 'freq': b.freq,
  135. 'gain': b.gain,
  136. 'q': b.q,
  137. 'filter_type': b.filter_type,
  138. 'enabled': b.enabled
  139. })
  140. with open(filepath, 'w') as f:
  141. json.dump(data, f, indent=4)
  142. def load_bands(self, filepath: str):
  143. import json
  144. with open(filepath, 'r') as f:
  145. data = json.load(f)
  146. new_bands = []
  147. for d in data:
  148. new_bands.append(EQBand(
  149. freq=d['freq'],
  150. gain=d['gain'],
  151. q=d['q'],
  152. filter_type=d['filter_type'],
  153. enabled=d.get('enabled', True)
  154. ))
  155. self.bands = new_bands
  156. self.update_filters()