|
- 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)
- }
- }
|