diff --git a/synth/config.go b/synth/config.go index 67fcb2b..7636102 100644 --- a/synth/config.go +++ b/synth/config.go @@ -11,6 +11,53 @@ const ( WhisperFloor = 0.03 // D-08/D-09: 3% of max amplitude ) +// WaveformType selects the harmonic preset for a synthesis layer. +// The zero value WaveformCustom preserves existing hand-tuned harmonics in FreqConfig.Harmonics. +type WaveformType int + +const ( + WaveformCustom WaveformType = iota // zero value: use FreqConfig.Harmonics as-is + WaveformSine // pure fundamental, single harmonic + WaveformSquare // odd harmonics with 1/k amplitude (bandlimited) + WaveformSawtooth // all harmonics with 1/k amplitude (bandlimited) + WaveformTriangle // odd harmonics with alternating 1/k^2 amplitude (bandlimited) +) + +// WaveformPresetHarmonics returns the bandlimited harmonic series for the given waveform type +// at the given base frequency and sample rate. Returns nil for WaveformCustom. +// All returned harmonics are below Nyquist (sampleRate/2). +func WaveformPresetHarmonics(wt WaveformType, baseHz float64, sampleRate int) []HarmonicDef { + nyquist := float64(sampleRate) / 2.0 + switch wt { + case WaveformCustom: + return nil + case WaveformSine: + return []HarmonicDef{{Ratio: 1, Amplitude: 1.0}} + case WaveformSquare: + var harmonics []HarmonicDef + for k := 1; float64(k)*baseHz < nyquist; k += 2 { + harmonics = append(harmonics, HarmonicDef{Ratio: k, Amplitude: 1.0 / float64(k)}) + } + return harmonics + case WaveformSawtooth: + var harmonics []HarmonicDef + for k := 1; float64(k)*baseHz < nyquist; k++ { + harmonics = append(harmonics, HarmonicDef{Ratio: k, Amplitude: 1.0 / float64(k)}) + } + return harmonics + case WaveformTriangle: + var harmonics []HarmonicDef + sign := 1.0 + for k := 1; float64(k)*baseHz < nyquist; k += 2 { + harmonics = append(harmonics, HarmonicDef{Ratio: k, Amplitude: sign / float64(k*k)}) + sign = -sign + } + return harmonics + default: + return nil + } +} + // HarmonicDef defines one partial in an additive synthesizer. type HarmonicDef struct { Ratio int // harmonic number: 1=fundamental, 2=octave, 3=fifth+octave, etc. @@ -19,29 +66,30 @@ type HarmonicDef struct { // FreqConfig holds synthesis parameters for one traffic class. type FreqConfig struct { - BaseHz float64 - Harmonics []HarmonicDef - Pan float64 // [-1, 1]: -1=full left, 0=center, +1=full right + BaseHz float64 + Harmonics []HarmonicDef + Pan float64 // [-1, 1]: -1=full left, 0=center, +1=full right + WaveformType WaveformType // zero value WaveformCustom uses Harmonics as-is } // ClassFreqConfigs maps each traffic class to its synthesis parameters. // Frequencies use musical intervals per D-02/D-03. Harmonics per D-05/D-06. // Pan positions per D-12. var ClassFreqConfigs = map[classify.TrafficClass]FreqConfig{ - classify.ClassICMP: {65.0, []HarmonicDef{{1, 1.0}, {2, 0.4}, {3, 0.15}}, 0.0}, - classify.ClassDNS: {110.0, []HarmonicDef{{1, 1.0}, {2, 0.5}, {3, 0.25}}, -0.2}, - classify.ClassHTTPS: {175.0, []HarmonicDef{{1, 1.0}, {2, 0.6}, {3, 0.3}}, 0.2}, - classify.ClassHTTP: {220.0, []HarmonicDef{{1, 1.0}, {2, 0.5}, {4, 0.2}}, -0.35}, - classify.ClassSSH: {330.0, []HarmonicDef{{1, 1.0}, {3, 0.6}, {5, 0.3}}, 0.35}, - classify.ClassSMTP: {440.0, []HarmonicDef{{1, 1.0}, {2, 0.3}, {3, 0.1}}, -0.55}, - classify.ClassNTP: {520.0, []HarmonicDef{{1, 1.0}, {2, 0.25}}, 0.55}, - classify.ClassDHCP: {600.0, []HarmonicDef{{1, 1.0}, {2, 0.35}, {3, 0.15}}, -0.75}, - classify.ClassOtherTCP: {700.0, []HarmonicDef{{1, 1.0}, {2, 0.2}}, 0.75}, - classify.ClassOtherUDP: {780.0, []HarmonicDef{{1, 1.0}, {2, 0.2}}, -0.75}, + classify.ClassICMP: {BaseHz: 65.0, Harmonics: []HarmonicDef{{1, 1.0}, {2, 0.4}, {3, 0.15}}, Pan: 0.0}, + classify.ClassDNS: {BaseHz: 110.0, Harmonics: []HarmonicDef{{1, 1.0}, {2, 0.5}, {3, 0.25}}, Pan: -0.2}, + classify.ClassHTTPS: {BaseHz: 175.0, Harmonics: []HarmonicDef{{1, 1.0}, {2, 0.6}, {3, 0.3}}, Pan: 0.2}, + classify.ClassHTTP: {BaseHz: 220.0, Harmonics: []HarmonicDef{{1, 1.0}, {2, 0.5}, {4, 0.2}}, Pan: -0.35}, + classify.ClassSSH: {BaseHz: 330.0, Harmonics: []HarmonicDef{{1, 1.0}, {3, 0.6}, {5, 0.3}}, Pan: 0.35}, + classify.ClassSMTP: {BaseHz: 440.0, Harmonics: []HarmonicDef{{1, 1.0}, {2, 0.3}, {3, 0.1}}, Pan: -0.55}, + classify.ClassNTP: {BaseHz: 520.0, Harmonics: []HarmonicDef{{1, 1.0}, {2, 0.25}}, Pan: 0.55}, + classify.ClassDHCP: {BaseHz: 600.0, Harmonics: []HarmonicDef{{1, 1.0}, {2, 0.35}, {3, 0.15}}, Pan: -0.75}, + classify.ClassOtherTCP: {BaseHz: 700.0, Harmonics: []HarmonicDef{{1, 1.0}, {2, 0.2}}, Pan: 0.75}, + classify.ClassOtherUDP: {BaseHz: 780.0, Harmonics: []HarmonicDef{{1, 1.0}, {2, 0.2}}, Pan: -0.75}, // D-05: Unknown buckets in 850-1100 Hz dissonant range, detuned intervals // D-06: Same dissonant harmonic character {1,1.0},{2,0.8},{3,0.4} for all 4 - classify.ClassUnknown1: {862.0, []HarmonicDef{{1, 1.0}, {2, 0.8}, {3, 0.4}}, 0.6}, - classify.ClassUnknown2: {920.0, []HarmonicDef{{1, 1.0}, {2, 0.8}, {3, 0.4}}, -0.6}, - classify.ClassUnknown3: {981.0, []HarmonicDef{{1, 1.0}, {2, 0.8}, {3, 0.4}}, 0.9}, - classify.ClassUnknown4: {1047.0, []HarmonicDef{{1, 1.0}, {2, 0.8}, {3, 0.4}}, -0.9}, + classify.ClassUnknown1: {BaseHz: 862.0, Harmonics: []HarmonicDef{{1, 1.0}, {2, 0.8}, {3, 0.4}}, Pan: 0.6}, + classify.ClassUnknown2: {BaseHz: 920.0, Harmonics: []HarmonicDef{{1, 1.0}, {2, 0.8}, {3, 0.4}}, Pan: -0.6}, + classify.ClassUnknown3: {BaseHz: 981.0, Harmonics: []HarmonicDef{{1, 1.0}, {2, 0.8}, {3, 0.4}}, Pan: 0.9}, + classify.ClassUnknown4: {BaseHz: 1047.0, Harmonics: []HarmonicDef{{1, 1.0}, {2, 0.8}, {3, 0.4}}, Pan: -0.9}, } diff --git a/synth/layer.go b/synth/layer.go index a07d018..8ec0b63 100644 --- a/synth/layer.go +++ b/synth/layer.go @@ -20,7 +20,11 @@ type Layer struct { } // NewLayer creates a Layer for the given config using the specified sample rate and EMA time constant (tau in seconds). +// If cfg.WaveformType is not WaveformCustom, harmonics are resolved from the preset at construction time. func NewLayer(cfg FreqConfig, sampleRate int, tau float64) *Layer { + if cfg.WaveformType != WaveformCustom { + cfg.Harmonics = WaveformPresetHarmonics(cfg.WaveformType, cfg.BaseHz, sampleRate) + } return &Layer{ Config: cfg, Osc: NewOscillator(cfg.BaseHz, sampleRate), diff --git a/synth/waveform_test.go b/synth/waveform_test.go new file mode 100644 index 0000000..cbfba02 --- /dev/null +++ b/synth/waveform_test.go @@ -0,0 +1,201 @@ +package synth_test + +import ( + "testing" + + "github.com/netsynth/netsynth/synth" +) + +func TestWaveformPresetHarmonics_Sine(t *testing.T) { + harmonics := synth.WaveformPresetHarmonics(synth.WaveformSine, 440.0, 44100) + if len(harmonics) != 1 { + t.Fatalf("WaveformSine: expected 1 harmonic, got %d", len(harmonics)) + } + if harmonics[0].Ratio != 1 { + t.Errorf("WaveformSine: expected Ratio=1, got %d", harmonics[0].Ratio) + } + if harmonics[0].Amplitude != 1.0 { + t.Errorf("WaveformSine: expected Amplitude=1.0, got %.4f", harmonics[0].Amplitude) + } +} + +func TestWaveformPresetHarmonics_Square(t *testing.T) { + harmonics := synth.WaveformPresetHarmonics(synth.WaveformSquare, 440.0, 44100) + if len(harmonics) == 0 { + t.Fatal("WaveformSquare: expected at least one harmonic, got 0") + } + nyquist := 22050.0 + for _, h := range harmonics { + if float64(h.Ratio)*440.0 >= nyquist { + t.Errorf("WaveformSquare: harmonic ratio %d exceeds Nyquist (freq=%.1f)", h.Ratio, float64(h.Ratio)*440.0) + } + expectedAmp := 1.0 / float64(h.Ratio) + if abs(h.Amplitude-expectedAmp) > 1e-9 { + t.Errorf("WaveformSquare: harmonic %d: expected amplitude %.6f, got %.6f", h.Ratio, expectedAmp, h.Amplitude) + } + } +} + +func TestWaveformPresetHarmonics_Sawtooth(t *testing.T) { + harmonics := synth.WaveformPresetHarmonics(synth.WaveformSawtooth, 440.0, 44100) + if len(harmonics) == 0 { + t.Fatal("WaveformSawtooth: expected at least one harmonic, got 0") + } + nyquist := 22050.0 + for _, h := range harmonics { + if float64(h.Ratio)*440.0 >= nyquist { + t.Errorf("WaveformSawtooth: harmonic ratio %d exceeds Nyquist (freq=%.1f)", h.Ratio, float64(h.Ratio)*440.0) + } + expectedAmp := 1.0 / float64(h.Ratio) + if abs(h.Amplitude-expectedAmp) > 1e-9 { + t.Errorf("WaveformSawtooth: harmonic %d: expected amplitude %.6f, got %.6f", h.Ratio, expectedAmp, h.Amplitude) + } + } +} + +func TestWaveformPresetHarmonics_Triangle(t *testing.T) { + harmonics := synth.WaveformPresetHarmonics(synth.WaveformTriangle, 440.0, 44100) + if len(harmonics) == 0 { + t.Fatal("WaveformTriangle: expected at least one harmonic, got 0") + } + nyquist := 22050.0 + sign := 1.0 + for i, h := range harmonics { + if float64(h.Ratio)*440.0 >= nyquist { + t.Errorf("WaveformTriangle: harmonic ratio %d exceeds Nyquist (freq=%.1f)", h.Ratio, float64(h.Ratio)*440.0) + } + expectedAmp := sign / float64(h.Ratio*h.Ratio) + if abs(h.Amplitude-expectedAmp) > 1e-9 { + t.Errorf("WaveformTriangle: harmonic %d (index %d): expected amplitude %.6f, got %.6f", h.Ratio, i, expectedAmp, h.Amplitude) + } + sign = -sign + } +} + +func TestWaveformPresetHarmonics_Custom(t *testing.T) { + harmonics := synth.WaveformPresetHarmonics(synth.WaveformCustom, 440.0, 44100) + if harmonics != nil { + t.Errorf("WaveformCustom: expected nil, got %v", harmonics) + } +} + +func TestBandlimitedHarmonicsNoAliasing(t *testing.T) { + waveforms := []synth.WaveformType{ + synth.WaveformSine, + synth.WaveformSquare, + synth.WaveformSawtooth, + synth.WaveformTriangle, + } + for _, cfg := range synth.ClassFreqConfigs { + for _, wt := range waveforms { + harmonics := synth.WaveformPresetHarmonics(wt, cfg.BaseHz, 44100) + for _, h := range harmonics { + freq := float64(h.Ratio) * cfg.BaseHz + if freq >= 22050.0 { + t.Errorf("waveform %d, baseHz=%.1f: harmonic ratio %d produces freq=%.1f >= Nyquist 22050", wt, cfg.BaseHz, h.Ratio, freq) + } + } + } + } +} + +func TestWaveformPresetHarmonics_SquareOddOnly(t *testing.T) { + harmonics := synth.WaveformPresetHarmonics(synth.WaveformSquare, 440.0, 44100) + for _, h := range harmonics { + if h.Ratio%2 == 0 { + t.Errorf("WaveformSquare: found even ratio %d (should be odd-only)", h.Ratio) + } + } +} + +func TestWaveformPresetHarmonics_TriangleOddOnly(t *testing.T) { + harmonics := synth.WaveformPresetHarmonics(synth.WaveformTriangle, 440.0, 44100) + for _, h := range harmonics { + if h.Ratio%2 == 0 { + t.Errorf("WaveformTriangle: found even ratio %d (should be odd-only)", h.Ratio) + } + } +} + +func TestWaveformPresetHarmonics_SawtoothConsecutive(t *testing.T) { + harmonics := synth.WaveformPresetHarmonics(synth.WaveformSawtooth, 440.0, 44100) + for i, h := range harmonics { + expected := i + 1 + if h.Ratio != expected { + t.Errorf("WaveformSawtooth: index %d: expected ratio %d, got %d", i, expected, h.Ratio) + } + } +} + +func TestNewLayerResolvesWaveformPreset(t *testing.T) { + cfg := synth.FreqConfig{ + BaseHz: 440.0, + WaveformType: synth.WaveformSquare, + // Harmonics intentionally empty — preset should be resolved + } + layer := synth.NewLayer(cfg, synth.SampleRate, 1.0) + if len(layer.Config.Harmonics) <= 1 { + t.Errorf("expected layer.Config.Harmonics to have length > 1 after preset resolution, got %d", len(layer.Config.Harmonics)) + } + if layer.Config.Harmonics[0].Ratio != 1 { + t.Errorf("expected first harmonic Ratio=1, got %d", layer.Config.Harmonics[0].Ratio) + } +} + +func TestNewLayerPreservesCustomHarmonics(t *testing.T) { + cfg := synth.FreqConfig{ + BaseHz: 440.0, + Harmonics: []synth.HarmonicDef{{Ratio: 1, Amplitude: 1.0}, {Ratio: 2, Amplitude: 0.4}}, + // WaveformType zero value = WaveformCustom + } + layer := synth.NewLayer(cfg, synth.SampleRate, 1.0) + if len(layer.Config.Harmonics) != 2 { + t.Errorf("expected exactly 2 harmonics preserved, got %d", len(layer.Config.Harmonics)) + } + if layer.Config.Harmonics[1].Amplitude != 0.4 { + t.Errorf("expected second harmonic Amplitude=0.4, got %.4f", layer.Config.Harmonics[1].Amplitude) + } +} + +func TestSineRegressionVsCustomHarmonics(t *testing.T) { + // Sine preset should produce identical output to a single-harmonic custom config + cfgSine := synth.FreqConfig{ + BaseHz: 440.0, + WaveformType: synth.WaveformSine, + } + cfgCustom := synth.FreqConfig{ + BaseHz: 440.0, + Harmonics: []synth.HarmonicDef{{Ratio: 1, Amplitude: 1.0}}, + } + // Use fast-converging tau for test + layerSine := synth.NewLayer(cfgSine, synth.SampleRate, 0.001) + layerCustom := synth.NewLayer(cfgCustom, synth.SampleRate, 0.001) + + // Set both to same target amplitude + layerSine.UpdateTarget(1, 1) + layerCustom.UpdateTarget(1, 1) + + // Advance enough samples for EMA to converge (tau=0.001 at 44100 SR: ~44 samples to 63%) + for i := 0; i < 200; i++ { + layerSine.AdvanceSample() + layerCustom.AdvanceSample() + } + + // Next 100 samples should match exactly + for i := 0; i < 100; i++ { + s1 := layerSine.AdvanceSample() + s2 := layerCustom.AdvanceSample() + if abs(s1-s2) > 1e-12 { + t.Errorf("sample %d: sine preset (%.10f) != custom harmonic (%.10f), diff=%.2e", i, s1, s2, abs(s1-s2)) + break + } + } +} + +// abs returns the absolute value of x. +func abs(x float64) float64 { + if x < 0 { + return -x + } + return x +}