diff --git a/synth/config_test.go b/synth/config_test.go new file mode 100644 index 0000000..9c2cffe --- /dev/null +++ b/synth/config_test.go @@ -0,0 +1,50 @@ +package synth_test + +import ( + "testing" + + "github.com/netsynth/netsynth/classify" + "github.com/netsynth/netsynth/synth" +) + +func TestAllClassesHaveConfig(t *testing.T) { + for _, class := range classify.AllClasses() { + if _, ok := synth.ClassFreqConfigs[class]; !ok { + t.Errorf("class %q has no entry in ClassFreqConfigs", class) + } + } +} + +func TestFrequenciesInRange(t *testing.T) { + for class, cfg := range synth.ClassFreqConfigs { + if cfg.BaseHz < 60 || cfg.BaseHz > 800 { + t.Errorf("class %q BaseHz=%.1f is out of range [60, 800]", class, cfg.BaseHz) + } + } +} + +func TestFrequenciesUnique(t *testing.T) { + seen := make(map[float64]classify.TrafficClass) + for class, cfg := range synth.ClassFreqConfigs { + if prev, exists := seen[cfg.BaseHz]; exists { + t.Errorf("classes %q and %q share the same BaseHz=%.1f", prev, class, cfg.BaseHz) + } + seen[cfg.BaseHz] = class + } +} + +func TestHarmonicsNonEmpty(t *testing.T) { + for class, cfg := range synth.ClassFreqConfigs { + if len(cfg.Harmonics) < 2 { + t.Errorf("class %q has fewer than 2 harmonics (got %d)", class, len(cfg.Harmonics)) + } + } +} + +func TestPanPositionsInRange(t *testing.T) { + for class, cfg := range synth.ClassFreqConfigs { + if cfg.Pan < -1.0 || cfg.Pan > 1.0 { + t.Errorf("class %q Pan=%.2f is out of range [-1.0, 1.0]", class, cfg.Pan) + } + } +} diff --git a/synth/layer_test.go b/synth/layer_test.go new file mode 100644 index 0000000..21bffd9 --- /dev/null +++ b/synth/layer_test.go @@ -0,0 +1,88 @@ +package synth_test + +import ( + "testing" + + "github.com/netsynth/netsynth/synth" +) + +func TestEMAAmplitudeRise(t *testing.T) { + cfg2 := synth.FreqConfig{ + BaseHz: 440.0, + Harmonics: []synth.HarmonicDef{{Ratio: 1, Amplitude: 1.0}}, + Pan: 0.0, + } + layer := synth.NewLayer(cfg2, synth.SampleRate, 1.0) + + // Force the layer to have been seen and set target to 1.0 + layer.UpdateTarget(100, 100) + + // After 1 second (SampleRate samples) with tau=1.0, currentAmp should be > 0.5 + // EMA: after tau seconds, amplitude reaches ~63% of target + for i := 0; i < synth.SampleRate; i++ { + layer.AdvanceSample() + } + if layer.CurrentAmp() <= 0.5 { + t.Errorf("expected currentAmp > 0.5 after 1 second rise, got %.4f", layer.CurrentAmp()) + } +} + +func TestEMAAmplitudeDecay(t *testing.T) { + cfg := synth.FreqConfig{ + BaseHz: 440.0, + Harmonics: []synth.HarmonicDef{{Ratio: 1, Amplitude: 1.0}}, + Pan: 0.0, + } + layer := synth.NewLayer(cfg, synth.SampleRate, 1.0) + + // Set up: layer has been seen (currentAmp starts at 1.0) and target is whisper floor + // We'll manually prime by updating target with 100/100 first then re-route to whisper + layer.UpdateTarget(100, 100) // mark as seen, target=1.0 + // Force currentAmp to 1.0 by running a few cycles at target 1.0 + for i := 0; i < synth.SampleRate*3; i++ { + layer.AdvanceSample() + } + // Now decay: set count=0 (whisper floor kicks in) + layer.UpdateTarget(0, 100) + // After 1 second, currentAmp should be < 0.5 + for i := 0; i < synth.SampleRate; i++ { + layer.AdvanceSample() + } + if layer.CurrentAmp() >= 0.5 { + t.Errorf("expected currentAmp < 0.5 after 1 second decay, got %.4f", layer.CurrentAmp()) + } +} + +func TestWhisperFloor(t *testing.T) { + cfg := synth.FreqConfig{ + BaseHz: 440.0, + Harmonics: []synth.HarmonicDef{{Ratio: 1, Amplitude: 1.0}}, + Pan: 0.0, + } + layer := synth.NewLayer(cfg, synth.SampleRate, 1.0) + + // Mark layer as seen by passing count=1, then set count=0 + layer.UpdateTarget(1, 100) + layer.UpdateTarget(0, 100) + + // Target should be whisper floor (not zero) because seen=true + if layer.TargetAmp() < synth.WhisperFloor { + t.Errorf("expected targetAmp >= WhisperFloor (%.2f) for seen layer at zero count, got %.4f", + synth.WhisperFloor, layer.TargetAmp()) + } +} + +func TestWhisperFloorNotSeenIsZero(t *testing.T) { + cfg := synth.FreqConfig{ + BaseHz: 440.0, + Harmonics: []synth.HarmonicDef{{Ratio: 1, Amplitude: 1.0}}, + Pan: 0.0, + } + layer := synth.NewLayer(cfg, synth.SampleRate, 1.0) + + // Never seen — UpdateTarget with zero count + layer.UpdateTarget(0, 100) + if layer.TargetAmp() != 0.0 { + t.Errorf("expected targetAmp == 0.0 for unseen layer, got %.4f", layer.TargetAmp()) + } +} diff --git a/synth/oscillator_test.go b/synth/oscillator_test.go new file mode 100644 index 0000000..7708013 --- /dev/null +++ b/synth/oscillator_test.go @@ -0,0 +1,59 @@ +package synth_test + +import ( + "testing" + + "github.com/netsynth/netsynth/synth" +) + +func TestOscillatorAdvance(t *testing.T) { + harmonics := []synth.HarmonicDef{{Ratio: 1, Amplitude: 1.0}, {Ratio: 2, Amplitude: 0.5}} + osc := synth.NewOscillator(440.0, synth.SampleRate) + + hasPos := false + hasNeg := false + for i := 0; i < 100; i++ { + s := osc.Advance(harmonics) + if s > 0 { + hasPos = true + } + if s < 0 { + hasNeg = true + } + } + if !hasPos || !hasNeg { + t.Errorf("expected both positive and negative samples; hasPos=%v hasNeg=%v", hasPos, hasNeg) + } +} + +func TestOscillatorPhaseWrap(t *testing.T) { + harmonics := []synth.HarmonicDef{{Ratio: 1, Amplitude: 1.0}} + osc := synth.NewOscillator(440.0, synth.SampleRate) + + for i := 0; i < synth.SampleRate; i++ { + osc.Advance(harmonics) + } + p := osc.Phase() + if p < 0 || p >= 1.0 { + t.Errorf("expected phase in [0, 1), got %.6f", p) + } +} + +func TestOscillatorDistinctFreqs(t *testing.T) { + harmonics := []synth.HarmonicDef{{Ratio: 1, Amplitude: 1.0}} + osc65 := synth.NewOscillator(65.0, synth.SampleRate) + osc440 := synth.NewOscillator(440.0, synth.SampleRate) + + same := true + for i := 0; i < 100; i++ { + s65 := osc65.Advance(harmonics) + s440 := osc440.Advance(harmonics) + if s65 != s440 { + same = false + break + } + } + if same { + t.Error("expected oscillators at 65 Hz and 440 Hz to produce different sample sequences") + } +}