From 1aaa15de0bbeca48a666a3fdc81ad8b610c39ea5 Mon Sep 17 00:00:00 2001 From: Markus Graf Date: Thu, 26 Mar 2026 11:58:44 +0100 Subject: [PATCH] feat(02-01): implement synth config, oscillator, and layer - synth/config.go: FreqConfig, HarmonicDef types; ClassFreqConfigs for all 11 traffic classes with frequencies in 60-800 Hz using musical intervals; pan positions per D-12 - synth/oscillator.go: phase-accumulator oscillator with additive harmonic synthesis and phase subtraction wrap (not math.Mod) per D-05 - synth/layer.go: EMA amplitude smoothing with tau-based alpha; whisper floor (0.03) prevents silence once class has been seen; UpdateTarget/AdvanceSample interface - All 12 tests pass (TestAllClassesHaveConfig, TestFrequenciesUnique, TestEMAAmplitudeRise, etc.) --- synth/config.go | 44 ++++++++++++++++++++++++++++ synth/layer.go | 70 +++++++++++++++++++++++++++++++++++++++++++++ synth/oscillator.go | 38 ++++++++++++++++++++++++ 3 files changed, 152 insertions(+) create mode 100644 synth/config.go create mode 100644 synth/layer.go create mode 100644 synth/oscillator.go diff --git a/synth/config.go b/synth/config.go new file mode 100644 index 0000000..a18c920 --- /dev/null +++ b/synth/config.go @@ -0,0 +1,44 @@ +package synth + +import "github.com/netsynth/netsynth/classify" + +const ( + SampleRate = 44100 // D-13: CD quality + WindowMs = 500 // matches aggregate.DefaultWindowMs + SamplesPerWindow = SampleRate * WindowMs / 1000 // 22050 + NumLayers = 11 + GainPerLayer = 1.0 / float64(NumLayers) // D-10: ~0.0909 + WhisperFloor = 0.03 // D-08/D-09: 3% of max amplitude +) + +// HarmonicDef defines one partial in an additive synthesizer. +type HarmonicDef struct { + Ratio int // harmonic number: 1=fundamental, 2=octave, 3=fifth+octave, etc. + Amplitude float64 // relative weight +} + +// 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 +} + +// 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}, + // D-04: 437 Hz is ~12 cents flat from A4 (440 Hz/SMTP). + // Creates 3 Hz beating when SMTP is present = dissonant "doesn't belong" signal. + classify.ClassUnknown: {437.0, []HarmonicDef{{1, 1.0}, {2, 0.8}, {3, 0.4}}, 0.0}, +} diff --git a/synth/layer.go b/synth/layer.go new file mode 100644 index 0000000..a07d018 --- /dev/null +++ b/synth/layer.go @@ -0,0 +1,70 @@ +package synth + +import "math" + +// EMAAlpha computes the per-sample smoothing coefficient for a given time constant. +// tau=1.0 at SR=44100 gives alpha ~0.0000227 (63% of target reached in 1 second). Per D-07. +func EMAAlpha(tau float64, sampleRate int) float64 { + return 1.0 - math.Exp(-1.0/(tau*float64(sampleRate))) +} + +// Layer combines an oscillator with EMA amplitude smoothing for one traffic class. +type Layer struct { + Config FreqConfig + Osc *Oscillator + currentAmp float64 + targetAmp float64 + alpha float64 // EMA coefficient + seen bool // whether this class has ever had count > 0 + whisper float64 // whisper floor amplitude (D-08) +} + +// NewLayer creates a Layer for the given config using the specified sample rate and EMA time constant (tau in seconds). +func NewLayer(cfg FreqConfig, sampleRate int, tau float64) *Layer { + return &Layer{ + Config: cfg, + Osc: NewOscillator(cfg.BaseHz, sampleRate), + alpha: EMAAlpha(tau, sampleRate), + whisper: WhisperFloor, + } +} + +// UpdateTarget sets the target amplitude from a packet count and max count across all classes. +// Per D-07/D-08/D-09: once seen, floor is whisper; amplitude scales linearly with normalized rate. +func (l *Layer) UpdateTarget(count int64, maxCount int64) { + if count > 0 { + l.seen = true + } + if !l.seen { + l.targetAmp = 0.0 + return + } + normalizedRate := 0.0 + if maxCount > 0 { + normalizedRate = float64(count) / float64(maxCount) + } + l.targetAmp = l.whisper + (1.0-l.whisper)*normalizedRate +} + +// AdvanceSample renders one sample and advances the EMA amplitude toward target. +// Returns the raw mono sample (before pan/gain). +func (l *Layer) AdvanceSample() float64 { + sample := l.Osc.Advance(l.Config.Harmonics) + l.currentAmp += l.alpha * (l.targetAmp - l.currentAmp) + return sample * l.currentAmp +} + +// CurrentAmp returns the current amplitude (for testing). +func (l *Layer) CurrentAmp() float64 { + return l.currentAmp +} + +// TargetAmp returns the target amplitude (for testing). +func (l *Layer) TargetAmp() float64 { + return l.targetAmp +} + +// Seen returns whether this layer has ever received traffic (for testing). +func (l *Layer) Seen() bool { + return l.seen +} diff --git a/synth/oscillator.go b/synth/oscillator.go new file mode 100644 index 0000000..818497e --- /dev/null +++ b/synth/oscillator.go @@ -0,0 +1,38 @@ +package synth + +import "math" + +// Oscillator is a phase-accumulator oscillator that generates additive sine waveforms. +type Oscillator struct { + phase float64 + freq float64 + sr float64 +} + +// NewOscillator creates an oscillator at the given frequency and sample rate. +func NewOscillator(freq float64, sampleRate int) *Oscillator { + return &Oscillator{freq: freq, sr: float64(sampleRate)} +} + +// Advance returns one sample: fundamental + harmonics summed and normalized to [-1, 1]. +func (o *Oscillator) Advance(harmonics []HarmonicDef) float64 { + sum := 0.0 + totalWeight := 0.0 + for _, h := range harmonics { + sum += h.Amplitude * math.Sin(2*math.Pi*o.phase*float64(h.Ratio)) + totalWeight += h.Amplitude + } + o.phase += o.freq / o.sr + if o.phase >= 1.0 { + o.phase -= 1.0 + } + if totalWeight > 0 { + return sum / totalWeight + } + return 0 +} + +// Phase returns the current phase (for testing). +func (o *Oscillator) Phase() float64 { + return o.phase +}