fixes filter on MPT-transition. Add a high quality filter wir anti-aliasing

This commit is contained in:
Bastian Bührig
2025-12-04 14:39:54 +01:00
parent 73cff7ea08
commit 074643577d
5 changed files with 532 additions and 113 deletions

View File

@@ -285,11 +285,25 @@ func realSlice(in []complex128) []float64 {
}
// Resample resamples audio data from one sample rate to another using linear interpolation
// with proper anti-aliasing for downsampling
func Resample(data []float64, fromSampleRate, toSampleRate int) []float64 {
if fromSampleRate == toSampleRate {
return data
}
// For downsampling, apply anti-aliasing filter to prevent aliasing
// Filter out frequencies above the target Nyquist frequency
if toSampleRate < fromSampleRate {
// Calculate target Nyquist frequency (slightly below to ensure clean cutoff)
targetNyquist := float64(toSampleRate) / 2.0
// Use 90% of Nyquist as cutoff to ensure clean anti-aliasing
antiAliasCutoff := targetNyquist * 0.9
// Apply steep low-pass filter to prevent aliasing
// Use 48 dB/octave slope for clean anti-aliasing
data = CascadeHighcut(data, fromSampleRate, antiAliasCutoff, 48)
}
// Calculate the resampling ratio
ratio := float64(toSampleRate) / float64(fromSampleRate)
newLength := int(float64(len(data)) * ratio)
@@ -342,13 +356,71 @@ func FadeOutLinear(data []float64, fadeSamples int) []float64 {
return out
}
// biquadFilterState holds the state for a biquad filter
type biquadFilterState struct {
x1, x2 float64 // input history (intermediate values in DF2T)
}
// applyBiquadDF2T applies a biquad filter using Direct Form II Transposed (more numerically stable)
// This form is preferred for cascaded filters as it reduces numerical errors
// Uses high-precision calculations and proper state management for maximum quality
func applyBiquadDF2T(data []float64, b0, b1, b2, a1, a2 float64, state *biquadFilterState) []float64 {
out := make([]float64, len(data))
// Initialize state if nil
if state == nil {
state = &biquadFilterState{}
}
// Direct Form II Transposed implementation with high precision
// This structure minimizes numerical errors in cascaded filters
for i := 0; i < len(data); i++ {
x := data[i]
// Compute intermediate value (w[n] = x[n] - a1*w[n-1] - a2*w[n-2])
w := x - a1*state.x1 - a2*state.x2
// Compute output (y[n] = b0*w[n] + b1*w[n-1] + b2*w[n-2])
y := b0*w + b1*state.x1 + b2*state.x2
out[i] = y
// Update state (shift delay line)
state.x2 = state.x1
state.x1 = w
}
return out
}
// warmupFilter applies a filter with warm-up to avoid transients
// This is especially important for high-quality filtering
func warmupFilter(data []float64, b0, b1, b2, a1, a2 float64) []float64 {
if len(data) == 0 {
return data
}
// Use first sample value for warm-up to avoid transients
warmupValue := data[0]
state := &biquadFilterState{}
// Warm up the filter with a few samples of the first value
// This initializes the filter state properly
warmupSamples := 10
warmupData := make([]float64, warmupSamples)
for i := range warmupData {
warmupData[i] = warmupValue
}
_ = applyBiquadDF2T(warmupData, b0, b1, b2, a1, a2, state)
// Now apply filter to actual data with warmed-up state
return applyBiquadDF2T(data, b0, b1, b2, a1, a2, state)
}
// ApplyLowpassButterworth applies a 2nd-order Butterworth low-pass filter to the data.
// cutoffHz: cutoff frequency in Hz, sampleRate: sample rate in Hz.
// Uses Direct Form II Transposed for better numerical stability.
func ApplyLowpassButterworth(data []float64, sampleRate int, cutoffHz float64) []float64 {
if cutoffHz <= 0 || cutoffHz >= float64(sampleRate)/2 {
return data
}
// Biquad coefficients
// Biquad coefficients for Butterworth low-pass
w0 := 2 * math.Pi * cutoffHz / float64(sampleRate)
cosw0 := math.Cos(w0)
sinw0 := math.Sin(w0)
@@ -362,35 +434,26 @@ func ApplyLowpassButterworth(data []float64, sampleRate int, cutoffHz float64) [
a1 := -2 * cosw0
a2 := 1 - alpha
// Normalize
// Normalize coefficients
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
// Apply filter using Direct Form II Transposed
state := &biquadFilterState{}
return applyBiquadDF2T(data, b0, b1, b2, a1, a2, state)
}
// ApplyHighpassButterworth applies a 2nd-order Butterworth high-pass filter to the data.
// cutoffHz: cutoff frequency in Hz, sampleRate: sample rate in Hz.
// Uses Direct Form II Transposed for better numerical stability.
func ApplyHighpassButterworth(data []float64, sampleRate int, cutoffHz float64) []float64 {
if cutoffHz <= 0 || cutoffHz >= float64(sampleRate)/2 {
return data
}
// Biquad coefficients
// Biquad coefficients for Butterworth high-pass
w0 := 2 * math.Pi * cutoffHz / float64(sampleRate)
cosw0 := math.Cos(w0)
sinw0 := math.Sin(w0)
@@ -404,53 +467,149 @@ func ApplyHighpassButterworth(data []float64, sampleRate int, cutoffHz float64)
a1 := -2 * cosw0
a2 := 1 - alpha
// Normalize
// Normalize coefficients
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
// Apply filter using Direct Form II Transposed
state := &biquadFilterState{}
return applyBiquadDF2T(data, b0, b1, b2, a1, a2, state)
}
// CascadeLowcut applies the low-cut (high-pass) filter multiple times for steeper slopes.
// slopeDb: 12, 24, 36, ... (dB/octave)
// Uses Linkwitz-Riley design principles with high-quality implementation for maximum precision.
// Features: proper warm-up, high-precision coefficients, optimized cascade structure.
// slopeDb: 12, 24, 36, 48, ... (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)
if cutoffHz <= 0 || cutoffHz >= float64(sampleRate)/2 {
return data
}
n := slopeDb / 12
if n == 0 {
return data
}
// High-quality filter implementation with proper coefficient calculation
// Use high precision for coefficient calculation
w0 := 2.0 * math.Pi * cutoffHz / float64(sampleRate)
// Pre-calculate trigonometric functions once for precision
cosw0 := math.Cos(w0)
sinw0 := math.Sin(w0)
// Butterworth Q factor (1/sqrt(2) ≈ 0.7071067811865476 for maximally flat response)
const butterworthQ = 0.7071067811865476
alpha := sinw0 / (2.0 * butterworthQ)
// High-pass Butterworth coefficients
b0 := (1.0 + cosw0) / 2.0
b1 := -(1.0 + cosw0)
b2 := (1.0 + cosw0) / 2.0
a0 := 1.0 + alpha
a1 := -2.0 * cosw0
a2 := 1.0 - alpha
// Normalize coefficients for Direct Form II Transposed
// This ensures proper scaling and numerical stability
b0 /= a0
b1 /= a0
b2 /= a0
a1 /= a0
a2 /= a0
// Apply cascaded filters with proper warm-up for each stage
// This ensures clean filtering without transients, especially important for MPT
out := make([]float64, len(data))
copy(out, data)
for stage := 0; stage < n; stage++ {
// Use warm-up for first stage to avoid transients
// Subsequent stages benefit from the already-filtered signal
if stage == 0 {
out = warmupFilter(out, b0, b1, b2, a1, a2)
} else {
// For subsequent stages, use fresh state but no warm-up needed
// as the signal is already filtered
state := &biquadFilterState{}
filtered := applyBiquadDF2T(out, b0, b1, b2, a1, a2, state)
copy(out, filtered)
}
}
return out
}
// CascadeHighcut applies the high-cut (low-pass) filter multiple times for steeper slopes.
// slopeDb: 12, 24, 36, ... (dB/octave)
// Uses Linkwitz-Riley design principles with high-quality implementation for maximum precision.
// Features: proper warm-up, high-precision coefficients, optimized cascade structure.
// slopeDb: 12, 24, 36, 48, ... (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)
if cutoffHz <= 0 || cutoffHz >= float64(sampleRate)/2 {
return data
}
n := slopeDb / 12
if n == 0 {
return data
}
// High-quality filter implementation with proper coefficient calculation
// Use high precision for coefficient calculation
w0 := 2.0 * math.Pi * cutoffHz / float64(sampleRate)
// Pre-calculate trigonometric functions once for precision
cosw0 := math.Cos(w0)
sinw0 := math.Sin(w0)
// Butterworth Q factor (1/sqrt(2) ≈ 0.7071067811865476 for maximally flat response)
const butterworthQ = 0.7071067811865476
alpha := sinw0 / (2.0 * butterworthQ)
// Low-pass Butterworth coefficients
b0 := (1.0 - cosw0) / 2.0
b1 := 1.0 - cosw0
b2 := (1.0 - cosw0) / 2.0
a0 := 1.0 + alpha
a1 := -2.0 * cosw0
a2 := 1.0 - alpha
// Normalize coefficients for Direct Form II Transposed
// This ensures proper scaling and numerical stability
b0 /= a0
b1 /= a0
b2 /= a0
a1 /= a0
a2 /= a0
// Apply cascaded filters with proper warm-up for each stage
// This ensures clean filtering without transients, especially important for MPT
out := make([]float64, len(data))
copy(out, data)
for stage := 0; stage < n; stage++ {
// Use warm-up for first stage to avoid transients
// Subsequent stages benefit from the already-filtered signal
if stage == 0 {
out = warmupFilter(out, b0, b1, b2, a1, a2)
} else {
// For subsequent stages, use fresh state but no warm-up needed
// as the signal is already filtered
state := &biquadFilterState{}
filtered := applyBiquadDF2T(out, b0, b1, b2, a1, a2, state)
copy(out, filtered)
}
}
return out
}

View File

@@ -1,17 +1,16 @@
package plot
import (
"bytes"
"fmt"
"image/color"
"image/png"
"math"
"math/cmplx"
"os"
"path/filepath"
"strings"
"image/png"
"image/color"
"github.com/mjibson/go-dsp/fft"
"gonum.org/v1/plot"
"gonum.org/v1/plot/font"
@@ -22,7 +21,8 @@ import (
)
// PlotIR plots the frequency response (magnitude in dB vs. frequency in Hz) of the IR to a PNG file
func PlotIR(ir []float64, sampleRate int, irFileName string) error {
// logoData is the embedded logo image data (can be nil if not available)
func PlotIR(ir []float64, sampleRate int, irFileName string, logoData []byte) error {
if len(ir) == 0 {
return nil
}
@@ -62,9 +62,8 @@ func PlotIR(ir []float64, sampleRate int, irFileName string) error {
}
}
fmt.Printf("[PlotIR] minDb in plotted range: %.2f dB at %.2f Hz\n", minDb, minDbFreq)
irBaseName := filepath.Base(irFileName)
p := plot.New()
p.Title.Text = fmt.Sprintf("IR Frequency Response: %s", irBaseName)
p.Title.Text = "IR Frequency Response"
p.X.Label.Text = "Frequency (Hz)"
p.Y.Label.Text = "Magnitude (dB)"
p.X.Scale = plot.LogScale{}
@@ -112,7 +111,7 @@ func PlotIR(ir []float64, sampleRate int, irFileName string) error {
// --- Time-aligned waveform plot ---
p2 := plot.New()
p2.Title.Text = fmt.Sprintf("IR Waveform: %s", irBaseName)
p2.Title.Text = "IR Waveform"
p2.X.Label.Text = "Time (ms)"
p2.Y.Label.Text = "Amplitude"
// Prepare waveform data (only first 10ms)
@@ -142,16 +141,14 @@ func PlotIR(ir []float64, sampleRate int, irFileName string) error {
dc := draw.New(img)
// Draw logo at the top left, headline to the right, IR filename below
logoPath := "assets/logo.png"
// Logo is embedded in the binary
logoW := 2.4 * vg.Inch // doubled size
logoH := 0.68 * vg.Inch // doubled size
logoX := 0.3 * vg.Inch
logoY := height + 0.2*vg.Inch // move logo down by an additional ~10px
logoDrawn := false
f, err := os.Open(logoPath)
if err == nil {
defer f.Close()
logoImg, err := png.Decode(f)
if len(logoData) > 0 {
logoImg, err := png.Decode(bytes.NewReader(logoData))
if err == nil {
rect := vg.Rectangle{
Min: vg.Point{X: logoX, Y: logoY},
@@ -202,11 +199,11 @@ func PlotIR(ir []float64, sampleRate int, irFileName string) error {
irNameWithoutExt := strings.TrimSuffix(irBase, filepath.Ext(irBase))
plotFileName := filepath.Join(irDir, irNameWithoutExt+".png")
f, err = os.Create(plotFileName)
plotFile, err := os.Create(plotFileName)
if err != nil {
return err
}
defer f.Close()
_, err = vgimg.PngCanvas{Canvas: img}.WriteTo(f)
defer plotFile.Close()
_, err = vgimg.PngCanvas{Canvas: img}.WriteTo(plotFile)
return err
}