Merge branch 'worktree-agent-a56cb818'

This commit is contained in:
2026-03-26 12:00:43 +01:00
9 changed files with 439 additions and 0 deletions
@@ -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
+1
View File
@@ -11,6 +11,7 @@ require (
require ( require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/sirupsen/logrus v1.9.3 // 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 github.com/spf13/pflag v1.0.9 // indirect
golang.org/x/net v0.39.0 // indirect golang.org/x/net v0.39.0 // indirect
golang.org/x/sys v0.32.0 // indirect golang.org/x/sys v0.32.0 // indirect
+2
View File
@@ -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/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 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= 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 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
+44
View File
@@ -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},
}
+50
View File
@@ -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)
}
}
}
+70
View File
@@ -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
}
+88
View File
@@ -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())
}
}
+38
View File
@@ -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
}
+59
View File
@@ -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")
}
}