diff --git a/README.md b/README.md index fd2c73e..bc59d6b 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ A CLI tool for processing WAV files to generate impulse responses (IR) from swee - **Fast FFT-based deconvolution** for accurate IR extraction - **Automatic input conversion:** Accepts any WAV sample rate, bit depth, or channel count - **Optional output IR length:** Specify output IR length in milliseconds with --length-ms +- **Optional low-cut and high-cut filtering:** Apply Butterworth filters to the recorded sweep before IR extraction (--lowcut, --highcut, --cut-slope) - **Automatic fade-out:** Linear fade-out at the end of the IR to avoid clicks (default 5 ms, configurable with --fade-ms) - **96kHz 24-bit WAV file support** for high-quality audio processing - **Multiple output formats** with configurable sample rates and bit depths @@ -68,6 +69,24 @@ By default, a 5 ms linear fade-out is applied to the end of the IR to avoid clic This applies a 10 ms fade-out at the end of the IR. +### Filtering the Recorded Sweep + +You can apply a low-cut (high-pass) and/or high-cut (low-pass) filter to the recorded sweep before IR extraction. This is useful for removing rumble, DC, or high-frequency noise: + +```sh +./valhallir-deconvolver --sweep sweep.wav --recorded recorded.wav --output ir.wav --lowcut 40 --highcut 18000 +``` + +This applies a 40 Hz low-cut (high-pass) and 18 kHz high-cut (low-pass) filter to the recorded sweep. + +You can control the filter steepness (slope) with `--cut-slope` (in dB/octave, default 12). For example: + +```sh +./valhallir-deconvolver --sweep sweep.wav --recorded recorded.wav --output ir.wav --lowcut 40 --highcut 18000 --cut-slope 24 +``` + +This applies a 40 Hz low-cut and 18 kHz high-cut, both with a 24 dB/octave slope (steeper than the default 12). + ### Different Output Formats Generate IRs in different sample rates and bit depths: @@ -128,6 +147,9 @@ Generate IRs in different sample rates and bit depths: | `--trim-threshold` | Silence threshold for trimming (0.0-1.0) | 0.001 | No | | `--length-ms` | Output IR length in milliseconds (trim or zero-pad) | - | No | | `--fade-ms` | Fade-out duration in milliseconds at end of IR (default 5) | 5 | No | +| `--lowcut` | Low-cut filter (high-pass) cutoff frequency in Hz (recorded sweep) | - | No | +| `--highcut` | High-cut filter (low-pass) cutoff frequency in Hz (recorded sweep) | - | No | +| `--cut-slope` | Filter slope in dB/octave (12, 24, 36, ...; default 12) | 12 | No | ## File Requirements @@ -159,6 +181,11 @@ Generate IRs in different sample rates and bit depths: - By default, a 5 ms linear fade-out is applied to the end of the IR (and MPT IR) to avoid clicks - You can change the fade duration with `--fade-ms` +### Filtering +- You can apply a Butterworth low-cut (high-pass) and/or high-cut (low-pass) filter to the recorded sweep before IR extraction +- Use `--lowcut` and/or `--highcut` to specify cutoff frequencies in Hz +- Use `--cut-slope` to control the filter steepness (12 dB/octave = gentle, 24+ = steeper) + ### Deconvolution Process 1. **FFT-based deconvolution** of recorded signal by sweep signal 2. **Regularization** to prevent division by zero diff --git a/main.go b/main.go index b38a332..fc11935 100644 --- a/main.go +++ b/main.go @@ -65,6 +65,19 @@ func main() { Usage: "Fade-out duration in milliseconds to apply at the end of the IR (default 5)", Value: 5.0, }, + &cli.Float64Flag{ + Name: "highcut", + Usage: "High-cut filter (low-pass) cutoff frequency in Hz (applied to recorded sweep, optional)", + }, + &cli.Float64Flag{ + Name: "lowcut", + Usage: "Low-cut filter (high-pass) cutoff frequency in Hz (applied to recorded sweep, optional)", + }, + &cli.IntFlag{ + Name: "cut-slope", + Usage: "Cut filter slope in dB/octave (12, 24, 36, 48, ...; default 12)", + Value: 12, + }, }, Action: func(c *cli.Context) error { // Read sweep WAV file @@ -82,8 +95,26 @@ func main() { log.Printf("Sweep: %d samples, %d channels", len(sweepData.PCMData), sweepData.Channels) log.Printf("Recorded: %d samples, %d channels", len(recordedData.PCMData), recordedData.Channels) + // Optionally filter the recorded sweep + recordedFiltered := recordedData.PCMData + recSampleRate := recordedData.SampleRate + highcutHz := c.Float64("highcut") + lowcutHz := c.Float64("lowcut") + cutSlope := c.Int("cut-slope") + if cutSlope < 12 || cutSlope%12 != 0 { + return fmt.Errorf("cut-slope must be a positive multiple of 12 (got %d)", cutSlope) + } + if lowcutHz > 0 { + log.Printf("Applying low-cut (high-pass) filter to recorded sweep: %.2f Hz, slope: %d dB/oct", lowcutHz, cutSlope) + recordedFiltered = convolve.CascadeLowcut(recordedFiltered, recSampleRate, lowcutHz, cutSlope) + } + if highcutHz > 0 { + log.Printf("Applying high-cut (low-pass) filter to recorded sweep: %.2f Hz, slope: %d dB/oct", highcutHz, cutSlope) + recordedFiltered = convolve.CascadeHighcut(recordedFiltered, recSampleRate, highcutHz, cutSlope) + } + log.Println("Performing deconvolution...") - ir := convolve.Deconvolve(sweepData.PCMData, recordedData.PCMData) + ir := convolve.Deconvolve(sweepData.PCMData, recordedFiltered) log.Printf("Deconvolution result: %d samples", len(ir)) log.Println("Trimming silence...") diff --git a/pkg/convolve/convolve.go b/pkg/convolve/convolve.go index 50625db..4257580 100644 --- a/pkg/convolve/convolve.go +++ b/pkg/convolve/convolve.go @@ -341,3 +341,115 @@ func FadeOutLinear(data []float64, fadeSamples int) []float64 { } return out } + +// ApplyLowpassButterworth applies a 2nd-order Butterworth low-pass filter to the data. +// cutoffHz: cutoff frequency in Hz, sampleRate: sample rate in Hz. +func ApplyLowpassButterworth(data []float64, sampleRate int, cutoffHz float64) []float64 { + if cutoffHz <= 0 || cutoffHz >= float64(sampleRate)/2 { + return data + } + // Biquad coefficients + w0 := 2 * math.Pi * cutoffHz / float64(sampleRate) + cosw0 := math.Cos(w0) + sinw0 := math.Sin(w0) + Q := 1.0 / math.Sqrt(2) // Butterworth Q + alpha := sinw0 / (2 * Q) + + b0 := (1 - cosw0) / 2 + b1 := 1 - cosw0 + b2 := (1 - cosw0) / 2 + a0 := 1 + alpha + a1 := -2 * cosw0 + a2 := 1 - alpha + + // Normalize + b0 /= a0 + b1 /= a0 + b2 /= a0 + a1 /= a0 + a2 /= a0 + + // Apply filter (Direct Form I) + out := make([]float64, len(data)) + var x1, x2, y1, y2 float64 + for i := 0; i < len(data); i++ { + x0 := data[i] + y0 := b0*x0 + b1*x1 + b2*x2 - a1*y1 - a2*y2 + out[i] = y0 + x2 = x1 + x1 = x0 + y2 = y1 + y1 = y0 + } + return out +} + +// ApplyHighpassButterworth applies a 2nd-order Butterworth high-pass filter to the data. +// cutoffHz: cutoff frequency in Hz, sampleRate: sample rate in Hz. +func ApplyHighpassButterworth(data []float64, sampleRate int, cutoffHz float64) []float64 { + if cutoffHz <= 0 || cutoffHz >= float64(sampleRate)/2 { + return data + } + // Biquad coefficients + w0 := 2 * math.Pi * cutoffHz / float64(sampleRate) + cosw0 := math.Cos(w0) + sinw0 := math.Sin(w0) + Q := 1.0 / math.Sqrt(2) // Butterworth Q + alpha := sinw0 / (2 * Q) + + b0 := (1 + cosw0) / 2 + b1 := -(1 + cosw0) + b2 := (1 + cosw0) / 2 + a0 := 1 + alpha + a1 := -2 * cosw0 + a2 := 1 - alpha + + // Normalize + b0 /= a0 + b1 /= a0 + b2 /= a0 + a1 /= a0 + a2 /= a0 + + // Apply filter (Direct Form I) + out := make([]float64, len(data)) + var x1, x2, y1, y2 float64 + for i := 0; i < len(data); i++ { + x0 := data[i] + y0 := b0*x0 + b1*x1 + b2*x2 - a1*y1 - a2*y2 + out[i] = y0 + x2 = x1 + x1 = x0 + y2 = y1 + y1 = y0 + } + return out +} + +// CascadeLowcut applies the low-cut (high-pass) filter multiple times for steeper slopes. +// slopeDb: 12, 24, 36, ... (dB/octave) +func CascadeLowcut(data []float64, sampleRate int, cutoffHz float64, slopeDb int) []float64 { + if slopeDb < 12 { + slopeDb = 12 + } + n := slopeDb / 12 + out := data + for i := 0; i < n; i++ { + out = ApplyHighpassButterworth(out, sampleRate, cutoffHz) + } + return out +} + +// CascadeHighcut applies the high-cut (low-pass) filter multiple times for steeper slopes. +// slopeDb: 12, 24, 36, ... (dB/octave) +func CascadeHighcut(data []float64, sampleRate int, cutoffHz float64, slopeDb int) []float64 { + if slopeDb < 12 { + slopeDb = 12 + } + n := slopeDb / 12 + out := data + for i := 0; i < n; i++ { + out = ApplyLowpassButterworth(out, sampleRate, cutoffHz) + } + return out +} diff --git a/testdata/ir.wav b/testdata/ir.wav index 32bfda3..c1f3c2c 100644 Binary files a/testdata/ir.wav and b/testdata/ir.wav differ diff --git a/testdata/ir_mpt.wav b/testdata/ir_mpt.wav new file mode 100644 index 0000000..80cc469 Binary files /dev/null and b/testdata/ir_mpt.wav differ