Bobby Collins
v4.2 — Super AI overhaul, pricing integration, Gemini reliability
160c1ee
"""Mid/Side stereo width processing with frequency-selective crossover."""
import numpy as np
from scipy.signal import butter, sosfiltfilt
# Crossover frequency: bass below this stays mono, width applies above
_CROSSOVER_HZ = 200.0
def linkwitz_riley_crossover(audio, sample_rate, crossover_hz):
"""Split audio into low and high bands using a Linkwitz-Riley 4th-order
crossover (two cascaded 2nd-order Butterworth filters).
Returns (low_band, high_band) each with the same shape as audio.
"""
nyquist = sample_rate / 2.0
# Clamp to valid range for the filter
freq = min(crossover_hz, nyquist * 0.95)
sos = butter(2, freq, btype='low', fs=sample_rate, output='sos')
# Linkwitz-Riley = two passes of Butterworth (sosfiltfilt = forward+backward
# = zero-phase, effectively 4th-order LR behaviour)
low = sosfiltfilt(sos, audio, axis=0).astype(np.float32)
high = (audio - low).astype(np.float32)
return low, high
def apply_stereo_width(audio, width_percent, sample_rate=44100):
"""Apply stereo width adjustment using frequency-selective M/S encoding.
Bass below 200 Hz stays mono-preserving (no width change) to keep
the low end tight and phase-coherent on club/mono systems.
Width adjustment applies only to frequencies above the crossover.
Args:
audio: numpy array of shape (samples, 2), float32.
width_percent: 80 to 150. 100 = no change.
sample_rate: int, sample rate for crossover filter.
Returns:
numpy array of shape (samples, 2), float32.
"""
if audio.ndim == 1 or audio.shape[1] == 1:
return audio
width_factor = width_percent / 100.0
# If width is unity, skip all processing
if width_factor == 1.0:
return audio
# Split into low (< 200 Hz) and high (>= 200 Hz) bands
low, high = linkwitz_riley_crossover(audio, sample_rate, _CROSSOVER_HZ)
# Apply M/S width to HIGH band only
left_h = high[:, 0]
right_h = high[:, 1]
mid = (left_h + right_h) / 2.0
side = (left_h - right_h) / 2.0
# Energy-preserving scaling
mid_scale = np.sqrt(2.0 / (1.0 + width_factor ** 2))
side_scale = width_factor * mid_scale
mid_out = mid * mid_scale
side_out = side * side_scale
left_out = mid_out + side_out
right_out = mid_out - side_out
high_widened = np.column_stack([left_out, right_out])
# Recombine: untouched low band + widened high band
result = low + high_widened
# Prevent clipping from width expansion
peak = np.max(np.abs(result))
if peak > 1.0:
result = result / peak
return result.astype(np.float32)