package dsp import ( "math" "testing" ) func TestCompositeClipperPeakLimit(t *testing.T) { // A signal at 2× ceiling must never exceed ceiling at output. c := NewCompositeClipper(CompositeClipperConfig{ Ceiling: 1.0, Iterations: 3, SoftKnee: 0, SampleRate: 228000, }) rate := 228000.0 n := int(rate * 0.05) // 50ms maxOut := 0.0 for i := 0; i < n; i++ { // 1kHz tone at amplitude 2.0 (200% modulation) in := 2.0 * math.Sin(2*math.Pi*1000*float64(i)/rate) out := c.Process(in) if math.Abs(out) > maxOut { maxOut = math.Abs(out) } } if maxOut > 1.001 { t.Errorf("peak %.6f exceeds ceiling 1.0", maxOut) } t.Logf("hard clip: peak=%.6f (ceiling=1.0)", maxOut) } func TestCompositeClipperSoftKneePeakLimit(t *testing.T) { c := NewCompositeClipper(CompositeClipperConfig{ Ceiling: 1.0, Iterations: 3, SoftKnee: 0.2, SampleRate: 228000, }) rate := 228000.0 n := int(rate * 0.05) maxOut := 0.0 for i := 0; i < n; i++ { in := 2.0 * math.Sin(2*math.Pi*1000*float64(i)/rate) out := c.Process(in) if math.Abs(out) > maxOut { maxOut = math.Abs(out) } } if maxOut > 1.001 { t.Errorf("soft clip peak %.6f exceeds ceiling 1.0", maxOut) } t.Logf("soft clip (knee=0.2): peak=%.6f", maxOut) } func TestCompositeClipperLookaheadPeakLimit(t *testing.T) { c := NewCompositeClipper(CompositeClipperConfig{ Ceiling: 1.0, Iterations: 3, SoftKnee: 0.15, LookaheadMs: 1.0, SampleRate: 228000, }) rate := 228000.0 n := int(rate * 0.10) // 100ms to let look-ahead settle maxOut := 0.0 for i := 0; i < n; i++ { in := 2.0 * math.Sin(2*math.Pi*1000*float64(i)/rate) out := c.Process(in) if math.Abs(out) > maxOut { maxOut = math.Abs(out) } } if maxOut > 1.001 { t.Errorf("lookahead peak %.6f exceeds ceiling 1.0", maxOut) } t.Logf("lookahead (1ms, soft knee=0.15, 3 iter): peak=%.6f", maxOut) } func TestCompositeClipperGuardBands(t *testing.T) { // Verify that after clipping, 19kHz and 57kHz energy is suppressed. // Feed a 1kHz sine at high level → clips → generates harmonics. // The clipper's notch filters should remove 19kHz and 57kHz. rate := 228000.0 n := int(rate * 0.1) // 100ms for _, tc := range []struct { name string iterations int softKnee float64 lookahead float64 }{ {"hard-1iter", 1, 0, 0}, {"hard-3iter", 3, 0, 0}, {"soft-3iter", 3, 0.15, 0}, {"lookahead-3iter", 3, 0.15, 1.0}, } { t.Run(tc.name, func(t *testing.T) { c := NewCompositeClipper(CompositeClipperConfig{ Ceiling: 1.0, Iterations: tc.iterations, SoftKnee: tc.softKnee, LookaheadMs: tc.lookahead, SampleRate: rate, }) out := make([]float64, n) for i := 0; i < n; i++ { in := 2.0 * math.Sin(2*math.Pi*1000*float64(i)/rate) out[i] = c.Process(in) } // Skip first 10ms for filter settling skip := int(rate * 0.01) analysis := out[skip:] e19 := GoertzelEnergy(analysis, rate, 19000) e57 := GoertzelEnergy(analysis, rate, 57000) e1k := GoertzelEnergy(analysis, rate, 1000) r19 := -100.0 r57 := -100.0 if e1k > 0 { if e19 > 0 { r19 = 10 * math.Log10(e19/e1k) } if e57 > 0 { r57 = 10 * math.Log10(e57/e1k) } } t.Logf("19kHz: %.1f dB below 1kHz, 57kHz: %.1f dB below 1kHz", -r19, -r57) // With 3 iterations, 19kHz should be suppressed by at least 40dB if tc.iterations >= 3 && r19 > -40 { t.Errorf("19kHz not sufficiently suppressed: %.1f dB (want < -40 dB)", r19) } }) } } func TestSoftClipContinuity(t *testing.T) { // Verify C1 continuity at the knee boundary ceiling := 1.0 knee := 0.2 threshold := ceiling - knee // 0.8 // Slope just below threshold (linear region) dx := 1e-8 y0 := SoftClip(threshold-dx, ceiling, knee) y1 := SoftClip(threshold+dx, ceiling, knee) slope := (y1 - y0) / (2 * dx) if math.Abs(slope-1.0) > 0.01 { t.Errorf("slope at knee boundary = %.4f, want ~1.0", slope) } // Verify output never exceeds ceiling for x := 0.0; x <= 5.0; x += 0.001 { y := SoftClip(x, ceiling, knee) if y > ceiling+1e-9 { t.Fatalf("SoftClip(%.3f) = %.6f > ceiling %.6f", x, y, ceiling) } } // Verify asymptotic approach to ceiling y5 := SoftClip(5.0, ceiling, knee) if y5 < 0.99 { t.Errorf("SoftClip(5.0) = %.6f, expected near ceiling (%.6f)", y5, ceiling) } } func TestCompositeClipperReset(t *testing.T) { c := NewCompositeClipper(CompositeClipperConfig{ Ceiling: 1.0, Iterations: 2, SoftKnee: 0.15, LookaheadMs: 1.0, SampleRate: 228000, }) // Process some samples for i := 0; i < 1000; i++ { c.Process(0.5) } stats := c.Stats() if stats.Envelope == 0 { t.Error("envelope should be non-zero after processing") } c.Reset() stats = c.Stats() if stats.Envelope != 0 { t.Errorf("envelope should be 0 after reset, got %f", stats.Envelope) } if stats.LookaheadGain != 1.0 { t.Errorf("gain should be 1.0 after reset, got %f", stats.LookaheadGain) } }