diff --git a/.planning/phases/02-audio-synthesis-engine/02-01-SUMMARY.md b/.planning/phases/02-audio-synthesis-engine/02-01-SUMMARY.md new file mode 100644 index 0000000..92231b9 --- /dev/null +++ b/.planning/phases/02-audio-synthesis-engine/02-01-SUMMARY.md @@ -0,0 +1,87 @@ +--- +phase: 02-audio-synthesis-engine +plan: "01" +subsystem: synth +tags: [synthesis, oscillator, EMA, harmonics, audio, go-lame, CGo] +dependency_graph: + requires: [classify/types.go] + provides: [synth/config.go, synth/oscillator.go, synth/layer.go] + affects: [synth/mixer.go (Plan 02-02)] +tech_stack: + added: [github.com/sjzar/go-lame@v0.0.9, gcc, ffprobe] + patterns: [phase-accumulator oscillator, EMA amplitude smoothing, additive synthesis, whisper floor] +key_files: + created: + - synth/config.go + - synth/oscillator.go + - synth/layer.go + - synth/config_test.go + - synth/oscillator_test.go + - synth/layer_test.go + modified: + - go.mod + - go.sum +decisions: + - "go-lame v0.0.9 added as indirect dependency (no direct usage yet); will be promoted to direct in Plan 02-02 when encoder is wired" + - "go-audio/wav intentionally excluded — writing interleaved int16 PCM bytes directly to LameWriter is simpler (per CONTEXT.md Claude's Discretion grant)" + - "ClassNTP has only 2 harmonics (not 3) — NTP is a minimal timing signal; 2 harmonics satisfy test requirement and match timbral intent" +metrics: + duration: "~15 minutes" + completed_date: "2026-03-26" + tasks_completed: 2 + files_created: 6 + files_modified: 2 +--- + +# Phase 02 Plan 01: Synth Foundation — Config, Oscillator, and Layer Summary + +**One-liner:** Phase-accumulator additive oscillator with EMA amplitude smoothing and whisper floor for 11 traffic-class drone layers, tested via TDD. + +## What Was Built + +### synth/config.go +Defines the frequency/harmonic/pan configuration table for all 11 traffic classes. Key constants: `SampleRate=44100`, `WhisperFloor=0.03`, `GainPerLayer=1/11`. Each class has a `FreqConfig` with `BaseHz` in 60-800 Hz, 2-3 `HarmonicDef` entries (ratio + amplitude), and a stereo pan position in [-1, 1]. + +Frequency assignments follow musical intervals (D-02/D-03): ICMP=65 Hz (deep bass), DNS=110 Hz, HTTPS=175 Hz, HTTP=220 Hz, SSH=330 Hz, SMTP=440 Hz, NTP=520 Hz, DHCP=600 Hz, other-TCP=700 Hz, other-UDP=780 Hz. ClassUnknown=437 Hz (detuned 3 Hz below SMTP for perceptible beating, per D-04). + +### synth/oscillator.go +Phase-accumulator oscillator. `Advance(harmonics []HarmonicDef) float64` sums weighted sine partials and normalizes by total weight. Phase advances by `freq/sampleRate` per sample with subtraction wrap (`o.phase -= 1.0`), not `math.Mod`, for performance. + +### synth/layer.go +`Layer` wraps an oscillator with EMA amplitude dynamics. `UpdateTarget(count, maxCount)` sets target amplitude: unseen layers stay at 0.0; seen layers floor at `WhisperFloor` and scale linearly to 1.0 at max count. `AdvanceSample()` advances the oscillator and the EMA (`currentAmp += alpha * (target - current)`). `EMAAlpha(tau, sr)` computes the per-sample coefficient for a given time constant in seconds. + +### Tests (12 passing) +- Config: `TestAllClassesHaveConfig`, `TestFrequenciesInRange`, `TestFrequenciesUnique`, `TestHarmonicsNonEmpty`, `TestPanPositionsInRange` +- Oscillator: `TestOscillatorAdvance`, `TestOscillatorPhaseWrap`, `TestOscillatorDistinctFreqs` +- Layer: `TestEMAAmplitudeRise`, `TestEMAAmplitudeDecay`, `TestWhisperFloor`, `TestWhisperFloorNotSeenIsZero` + +## Commits + +| Task | Commit | Description | +|------|--------|-------------| +| 1 — environment setup | dc7aed2 | chore(02-01): install gcc, ffprobe, and go-lame dependency | +| 2 — tests (RED) | bb13527 | test(02-01): add failing tests for synth config, oscillator, and layer | +| 2 — implementation (GREEN) | 1aaa15d | feat(02-01): implement synth config, oscillator, and layer | + +## Deviations from Plan + +None — plan executed exactly as written. + +## Known Stubs + +None — all config entries are fully wired with real frequency/harmonic/pan values. No placeholder data. + +## Self-Check: PASSED + +- synth/config.go: FOUND +- synth/oscillator.go: FOUND +- synth/layer.go: FOUND +- synth/config_test.go: FOUND +- synth/oscillator_test.go: FOUND +- synth/layer_test.go: FOUND +- Commit dc7aed2: FOUND +- Commit bb13527: FOUND +- Commit 1aaa15d: FOUND +- `go test ./synth/...`: 12 tests PASS +- `CGO_ENABLED=1 go build ./...`: PASS +- go.mod contains go-lame v0.0.9: VERIFIED diff --git a/go.mod b/go.mod index 867701e..fb3e5e4 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/sirupsen/logrus v1.9.3 // indirect + github.com/sjzar/go-lame v0.0.9 // indirect github.com/spf13/pflag v1.0.9 // indirect golang.org/x/net v0.39.0 // indirect golang.org/x/sys v0.32.0 // indirect diff --git a/go.sum b/go.sum index 47eed5a..43e60b1 100644 --- a/go.sum +++ b/go.sum @@ -13,6 +13,8 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/sjzar/go-lame v0.0.9 h1:x/X3I+uKVsx8WeKO5cu4fvkAbm2okOaP1ibeeI3urAU= +github.com/sjzar/go-lame v0.0.9/go.mod h1:8RmqWcAKSbBAk6bTRV9d8mdDxqK3hY9vFyoJ4DoQE6Y= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= 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/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.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/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.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 +} 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") + } +}