| """Mid/Side stereo width processing with frequency-selective crossover.""" |
|
|
| import numpy as np |
| from scipy.signal import butter, sosfiltfilt |
|
|
|
|
| |
| _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 |
| |
| freq = min(crossover_hz, nyquist * 0.95) |
|
|
| sos = butter(2, freq, btype='low', fs=sample_rate, output='sos') |
|
|
| |
| |
| 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_factor == 1.0: |
| return audio |
|
|
| |
| low, high = linkwitz_riley_crossover(audio, sample_rate, _CROSSOVER_HZ) |
|
|
| |
| left_h = high[:, 0] |
| right_h = high[:, 1] |
|
|
| mid = (left_h + right_h) / 2.0 |
| side = (left_h - right_h) / 2.0 |
|
|
| |
| 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]) |
|
|
| |
| result = low + high_widened |
|
|
| |
| peak = np.max(np.abs(result)) |
| if peak > 1.0: |
| result = result / peak |
|
|
| return result.astype(np.float32) |
|
|