fixes filter on MPT-transition. Add a high quality filter wir anti-aliasing
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user