diff --git a/src/dsp/pipeline.py b/src/dsp/pipeline.py index ce025de..1fab85c 100644 --- a/src/dsp/pipeline.py +++ b/src/dsp/pipeline.py @@ -352,14 +352,23 @@ class AudioPipeline: # load_preset() holds it only for the atomic swap (not during model I/O). self._lock = threading.Lock() - # ── DC-blocking filter state ─────────────────────────────────────── - # Single-pole high-pass at ~15Hz to remove DC offset and subsonic - # noise that can amplify into audible hum through NAM models. - # Filter: y[n] = alpha * (y[n-1] + x[n] - x[n-1]) - # where alpha = 1 / (1 + 2*pi*fc/sr) - self._dc_prev_x: float = 0.0 - self._dc_prev_y: float = 0.0 - self._dc_alpha: float = 1.0 / (1.0 + 2.0 * np.pi * 15.0 / 48000.0) + # ── Mains hum notch filter state (60Hz notch) ─────────────────────── + # Biquad notch filter to remove 60Hz mains hum without affecting + # guitar frequencies (lowest open string is 82Hz E2). + # Direct Form I state: two input delays (x1, x2) and two output delays (y1, y2) + self._notch_x1: float = 0.0 + self._notch_x2: float = 0.0 + self._notch_y1: float = 0.0 + self._notch_y2: float = 0.0 + self._notch_b0: float = 1.0 + self._notch_b1: float = 0.0 + self._notch_b2: float = 0.0 + self._notch_a1: float = 0.0 + self._notch_a2: float = 0.0 + # Compute initial coefficients for 60Hz notch with Q=5 + _b0, _b1, _b2, _a1, _a2 = _compute_notch_coeffs(60.0, 5.0, 48000.0) + self._notch_b0, self._notch_b1, self._notch_b2 = _b0, _b1, _b2 + self._notch_a1, self._notch_a2 = _a1, _a2 logger.info("Audio pipeline initialized (block=%d, sr=%d)", self._block_size, self._sample_rate) @@ -655,20 +664,25 @@ class AudioPipeline: ) buf = audio_in.copy() - # ── DC-blocking high-pass filter ──────────────────────────── - # Removes DC offset and subsonic noise (~15Hz corner) that can - # amplify into audible hum through high-gain NAM models. - # First-order HPF: y[n] = alpha * (y[n-1] + x[n] - x[n-1]) - alpha = self._dc_alpha - px = self._dc_prev_x - py = self._dc_prev_y + # ── 60Hz mains hum notch filter ───────────────────────────── + # Biquad notch at 60Hz (Q=5) to remove mains hum without + # affecting guitar frequencies (lowest open string is 82Hz E2). + b0, b1, b2 = self._notch_b0, self._notch_b1, self._notch_b2 + a1, a2 = self._notch_a1, self._notch_a2 + x1, x2 = self._notch_x1, self._notch_x2 + y1, y2 = self._notch_y1, self._notch_y2 for i in range(len(buf)): - y = alpha * (py + buf[i] - px) - px = buf[i] - py = y + x = buf[i] + y = b0*x + b1*x1 + b2*x2 - a1*y1 - a2*y2 + x2 = x1 + x1 = x + y2 = y1 + y1 = y buf[i] = y - self._dc_prev_x = px - self._dc_prev_y = py + self._notch_x1 = x1 + self._notch_x2 = x2 + self._notch_y1 = y1 + self._notch_y2 = y2 for idx, entry in enumerate(chain): if entry["bypass"] or not entry["enabled"]: @@ -707,22 +721,27 @@ class AudioPipeline: ch0 = audio_in[0, :].copy() ch1 = audio_in[1, :].copy() - # ── DC-blocking high-pass filter on both channels ─────────── - alpha = self._dc_alpha - px = self._dc_prev_x - py = self._dc_prev_y + # ── 60Hz mains hum notch filter on both channels ──────────── + b0, b1, b2 = self._notch_b0, self._notch_b1, self._notch_b2 + a1, a2 = self._notch_a1, self._notch_a2 + # Apply notch to ch0 + x1, x2 = self._notch_x1, self._notch_x2 + y1, y2 = self._notch_y1, self._notch_y2 for i in range(len(ch0)): - y = alpha * (py + ch0[i] - px) - px = ch0[i] - py = y + x = ch0[i] + y = b0*x + b1*x1 + b2*x2 - a1*y1 - a2*y2 + x2 = x1; x1 = x; y2 = y1; y1 = y ch0[i] = y + # Apply notch to ch1 (continues from ch0's state) for i in range(len(ch1)): - y = alpha * (py + ch1[i] - px) - px = ch1[i] - py = y + x = ch1[i] + y = b0*x + b1*x1 + b2*x2 - a1*y1 - a2*y2 + x2 = x1; x1 = x; y2 = y1; y1 = y ch1[i] = y - self._dc_prev_x = px - self._dc_prev_y = py + self._notch_x1 = x1 + self._notch_x2 = x2 + self._notch_y1 = y1 + self._notch_y2 = y2 # Update input VU level from the guitar input channel (ch0) in_rms = np.sqrt(np.mean(ch0 ** 2) + _EPS) @@ -3143,7 +3162,13 @@ class AudioPipeline: self._block_size = block_size self._sample_rate = sample_rate self._vu_alpha = np.exp(-block_size / (0.05 * sample_rate)) - self._dc_alpha = 1.0 / (1.0 + 2.0 * np.pi * 15.0 / sample_rate) + # Recompute 60Hz notch coefficients for new sample rate + _b0, _b1, _b2, _a1, _a2 = _compute_notch_coeffs(60.0, 5.0, sample_rate) + self._notch_b0, self._notch_b1, self._notch_b2 = _b0, _b1, _b2 + self._notch_a1, self._notch_a2 = _a1, _a2 + # Reset notch filter state to avoid pop on rate change + self._notch_x1 = self._notch_x2 = 0.0 + self._notch_y1 = self._notch_y2 = 0.0 # Clear DSP state — effects will reinit with new block/sample rate self._state.clear() self._coeffs.clear()