# Architecture Patterns **Domain:** Network traffic sonification CLI (Go) — v1.1 Custom Sound Mappings **Researched:** 2026-03-26 **Confidence:** HIGH — based on direct code inspection of the existing v1.0 codebase --- ## v1.1 Integration Overview This document supersedes the pre-implementation v1.0 architecture research. It is grounded in the actual codebase (3,254 lines, 6 packages) and answers: what changes, what's new, and in what order. --- ## Existing Package Map (v1.0 Baseline) ``` cmd/netsynth/main.go CLI, pipeline wiring, Cobra flags capture/ go-pcap live capture + pcap file reader + BPF classify/ types.go TrafficClass, ClassifiedPacket, WindowSnapshot classifier.go NewClassifier(rules []Rule) — first-match-wins rules.go DefaultRules []Rule (12 hardcoded rules) aggregate/ window.go 500ms time-windowed snapshot accumulation synth/ config.go ClassFreqConfigs — fixed map[TrafficClass]FreqConfig oscillator.go Phase-accumulator oscillator — sine only layer.go EMA amplitude smoothing per layer bank.go NewBank(tau) — one Layer per AllClasses() mixer.go PanGains, StereoFramesToInt16Bytes encode/ mp3.go RunSynthesis(snapshots, path) — NewBank + EncodeMP3 ``` --- ## What v1.1 Adds Three independent but related features: 1. **TOML config file** — override frequencies and waveforms per built-in class 2. **Additional waveforms** — square, sawtooth, triangle alongside existing sine 3. **User-defined classification rules** — TOML-defined rules prepended before DefaultRules --- ## Integration Point Analysis ### Feature 1: TOML Config File **Where config is consumed today:** `synth/config.go` holds a package-level `var ClassFreqConfigs`. `synth/bank.go:NewBank()` reads it directly with `ClassFreqConfigs[class]`. No config is passed through `encode.RunSynthesis` or `main.go`. **Required change:** `NewBank` must accept a config parameter instead of reading the global. `encode.RunSynthesis` must accept and forward a config. `main.go` must load config from disk and pass it in. **New package: `config/`** This package does not exist yet in the codebase (the pre-implementation research anticipated it but it was deferred). It should own: - TOML struct definitions - File discovery logic (auto-detect `./netsynth.toml`, then `~/.config/netsynth/config.toml`) - Merging: loaded config overlays defaults, does not replace them entirely ``` config/ config.go Config struct, Load(path string) (*Config, error) defaults.go DefaultConfig() — wraps existing ClassFreqConfigs values ``` **TOML struct shape:** ```toml [[class]] name = "HTTPS" frequency_hz = 200.0 waveform = "sawtooth" [[class]] name = "myservice" # user-defined class (Feature 3) frequency_hz = 350.0 waveform = "triangle" ``` The `Config` struct passed into `NewBank` should merge with `ClassFreqConfigs`: ```go // config/config.go type ClassConfig struct { Name string `toml:"name"` FrequencyHz float64 `toml:"frequency_hz"` Waveform string `toml:"waveform"` // "sine" | "square" | "sawtooth" | "triangle" } type Config struct { Classes []ClassConfig `toml:"class"` Rules []RuleConfig `toml:"rule"` // Feature 3 } ``` **TOML library:** Use `github.com/BurntSushi/toml`. It is the de-facto standard for TOML in Go (used by Hugo, dep, buf, etc.). Already a transitive dependency in many Go module graphs. Provides struct-tag-based decode, good error messages. --- ### Feature 2: Additional Waveforms **Where waveform logic lives today:** `synth/oscillator.go:Advance()` — pure sine via `math.Sin`. The `HarmonicDef.Ratio` and `HarmonicDef.Amplitude` fields are stored in `FreqConfig.Harmonics` but the waveform function is hardcoded. **Required change:** `Oscillator.Advance` must dispatch on a waveform type. Two clean approaches: **Option A (recommended): Waveform enum on Oscillator** Add a `waveform` field to `Oscillator`. `Advance` switches on it. `NewOscillator` gains a waveform parameter. ```go type Waveform int const ( WaveformSine Waveform = iota WaveformSquare WaveformSawtooth WaveformTriangle ) type Oscillator struct { phase float64 freq float64 sr float64 waveform Waveform } func (o *Oscillator) sampleAt(phase, ratio float64) float64 { p := phase * float64(ratio) p -= math.Floor(p) // wrap to [0, 1) switch o.waveform { case WaveformSquare: if p < 0.5 { return 1.0 } return -1.0 case WaveformSawtooth: return 2.0*p - 1.0 case WaveformTriangle: if p < 0.5 { return 4.0*p - 1.0 } return 3.0 - 4.0*p default: // WaveformSine return math.Sin(2 * math.Pi * p) } } ``` **Option B: Function field on Oscillator** Store `waveFn func(phase float64) float64`. More flexible but harder to serialize/configure. Option A is preferred because waveform type maps cleanly to the TOML `waveform` string field without reflection tricks. **`FreqConfig` change:** Add `Waveform` field: ```go type FreqConfig struct { BaseHz float64 Harmonics []HarmonicDef Pan float64 Waveform Waveform // NEW: defaults to WaveformSine } ``` `NewLayer` passes `cfg.Waveform` to `NewOscillator`. `NewOscillator` signature changes to accept the waveform. **What does NOT change:** `HarmonicDef`, `EMAAlpha`, `Layer.UpdateTarget`, `Layer.AdvanceSample`, `OscillatorBank.RenderWindow`, `mixer.go`, `encode/mp3.go`. The waveform change is contained to `oscillator.go` and the `FreqConfig` struct. --- ### Feature 3: User-Defined Classification Rules **Where rules are wired today:** `main.go` lines 111, 175 — both `runLiveMode` and `runPcapMode` call `classify.NewClassifier(classify.DefaultRules)` directly. No config is passed. **Required change:** User rules from TOML prepend before `DefaultRules`. `Classifier` already supports arbitrary `[]Rule` — `NewClassifier(rules []Rule)` is the constructor. No change to `classifier.go` itself. **`RuleConfig` TOML struct:** ```toml [[rule]] protocol = "tcp" dst_port = 8443 class = "myservice" ``` ```go // config/config.go type RuleConfig struct { Protocol string `toml:"protocol"` DstPort uint16 `toml:"dst_port"` Class string `toml:"class"` // must match a name in [[class]] or a builtin class name } ``` **Merging in main.go:** ```go userRules := config.ToClassifyRules(cfg.Rules) // []classify.Rule allRules := append(userRules, classify.DefaultRules...) classifier := classify.NewClassifier(allRules) ``` **New `TrafficClass` values:** User-defined classes in TOML produce new `TrafficClass` string values (e.g., `"myservice"`). `AllClasses()` in `classify/types.go` is currently a hardcoded slice. For user-defined classes, `AllClasses()` cannot be the source of truth for bank layer construction. `NewBank` must instead iterate over whatever classes have a `FreqConfig` entry. This is a critical integration point: `bank.go:NewBank` currently ranges over `classify.AllClasses()`. If user classes can appear, `NewBank` must accept the full config map and range over that instead. --- ## New vs Modified Components ### New | Component | Location | Purpose | |-----------|----------|---------| | `config` package | `config/config.go` | TOML struct, `Load()`, file discovery, merge with defaults | | `config/defaults.go` | optional split | `DefaultConfig()` wrapping existing `ClassFreqConfigs` values | ### Modified | Component | Change | Impact | |-----------|--------|--------| | `synth/oscillator.go` | Add `Waveform` type + `waveform` field; dispatch in `Advance` | Self-contained; no caller signature breaks except `NewOscillator` | | `synth/config.go` | Add `Waveform Waveform` field to `FreqConfig`; default to `WaveformSine` | Requires `NewLayer` to pass waveform to `NewOscillator` | | `synth/layer.go` | Pass `cfg.Waveform` to `NewOscillator` | One-line change | | `synth/bank.go` | Accept `map[classify.TrafficClass]FreqConfig` param instead of reading global; range over param keys not `AllClasses()` | Decouples bank from global; enables user classes | | `encode/mp3.go` | Accept `*config.Config` or merged `FreqConfig` map; pass to `NewBank` | Thin forwarding change | | `cmd/netsynth/main.go` | Add `--config` flag; load config; prepend user rules; pass config to `RunSynthesis` | Touches both `runLiveMode` and `runPcapMode` | | `classify/rules.go` | No change — `DefaultRules` stays as the fallback | Unchanged | | `classify/classifier.go` | No change — already accepts `[]Rule` | Unchanged | | `classify/types.go` | `AllClasses()` may need a note that it returns only builtins; bank no longer relies on it | Low risk; document only | --- ## Data Flow Changes ### v1.0 Flow (config hardcoded) ``` main.go └─ classify.NewClassifier(classify.DefaultRules) └─ encode.RunSynthesis(snapshots, path) └─ synth.NewBank(1.0) └─ ClassFreqConfigs[class] ← global, hardcoded ``` ### v1.1 Flow (config injected) ``` main.go └─ config.Load(configPath) ← NEW: resolve path, parse TOML, merge defaults └─ cfg *config.Config └─ classify.NewClassifier( append(config.ToClassifyRules(cfg.Rules), classify.DefaultRules...) ) ← user rules prepend built-ins └─ encode.RunSynthesis(snapshots, path, cfg.FreqConfigs()) └─ synth.NewBank(1.0, freqConfigs) ← map passed in, not read from global └─ freqConfigs[class] ← merged: user overrides + defaults ``` --- ## Suggested Build Order The following order minimizes integration risk. Each step is independently testable before the next begins. ### Step 1: Waveform types in `synth/oscillator.go` No external dependencies. Pure math. Testable with golden-sample unit tests (square wave sample at phase 0.25 should be 1.0, etc.). Does not affect `Layer`, `Bank`, or `encode` yet. **Files changed:** `synth/oscillator.go` only. ### Step 2: Wire `Waveform` through `FreqConfig` and `Layer` Add `Waveform` to `FreqConfig`. Update `NewLayer` to pass it to `NewOscillator`. `ClassFreqConfigs` entries default to `WaveformSine` (zero value — valid if `WaveformSine = 0`). Existing tests continue to pass without modification since all existing configs use the zero-value waveform. **Files changed:** `synth/config.go`, `synth/layer.go`. ### Step 3: Decouple `NewBank` from the global Change `NewBank(tau float64)` to `NewBank(tau float64, cfgs map[classify.TrafficClass]FreqConfig)`. Update `encode/mp3.go:RunSynthesis` to pass `synth.ClassFreqConfigs` as default. At this point the system is functionally identical to v1.0 but `NewBank` no longer reads a global. **Files changed:** `synth/bank.go`, `encode/mp3.go`. ### Step 4: `config` package — TOML structs and file discovery Implement `config.Load()`, file discovery, and the `DefaultConfig()` function that wraps `synth.ClassFreqConfigs`. No TOML parsing yet — start with the struct definitions and the merge logic. Add `github.com/BurntSushi/toml` dependency (`go get`). **Files added:** `config/config.go`. ### Step 5: `--config` flag and user rule merging in `main.go` Wire `config.Load()` into `run()`. Pass user rules to both `runLiveMode` and `runPcapMode`. Pass merged `FreqConfig` map to `RunSynthesis`. At this point a minimal TOML config (empty file, or `[[rule]]` only) can be validated end-to-end. **Files changed:** `cmd/netsynth/main.go`. ### Step 6: Custom frequency and waveform overrides in config Implement the `[[class]]` TOML section parsing. Add `FreqConfigs()` method to `Config` that returns the merged map (user overrides applied over defaults). Write table-driven tests: "TOML sets HTTPS to 200 Hz sawtooth, bank layer for HTTPS uses 200 Hz sawtooth." **Files changed:** `config/config.go`. ### Step 7: User-defined classes end-to-end Support `[[class]]` entries with names not in `classify.AllClasses()`. These become new `TrafficClass` values. User `[[rule]]` entries pointing to these classes are prepended to `DefaultRules`. The bank creates layers for all classes in the merged `FreqConfig` map. This step requires the most cross-package coordination but by this point each piece is already in place. **Files changed:** `config/config.go`, `cmd/netsynth/main.go` (verification that unknown class names don't panic). --- ## Component Boundaries After v1.1 | Component | Responsibility | Communicates With | |-----------|---------------|-------------------| | `config` | TOML parsing, file discovery, merge logic, `DefaultConfig()` | `synth` (FreqConfig type), `classify` (Rule type) | | `synth/oscillator` | Phase-accumulator for sine/square/sawtooth/triangle | Used by `Layer` | | `synth/bank` | Accepts freq config map, constructs one `Layer` per entry | `encode` passes config map in | | `encode` | Receives config map from `main`, passes to `NewBank` | Thin pass-through | | `cmd/netsynth/main` | Loads config, merges rules, wires all stages | All packages | | `classify` | Rules engine (unchanged); `DefaultRules` stays as package-level var | `main` constructs with merged rules | --- ## Critical Integration Constraints ### `AllClasses()` Is Not the Source of Truth for Bank Construction `bank.go` currently iterates `classify.AllClasses()` to construct layers. After v1.1, the bank must iterate the keys of the `FreqConfig` map passed to it. User-defined classes will not appear in `AllClasses()`. If this is not changed, user-defined class packets will be aggregated in `WindowSnapshot.Counts` but have no corresponding layer — they will produce silence and no error. **Fix:** `NewBank` iterates `maps.Keys(cfgs)` (or equivalent range over the map), not `classify.AllClasses()`. ### Class Name Validation Must Happen at Config Load Time If a `[[rule]]` references a class name that has no corresponding `[[class]]` entry and is not a builtin, the system will silently mis-classify packets into a layer that doesn't exist. Validate at `config.Load()` time: every class name in `[[rule]]` must resolve to either a builtin `TrafficClass` or a `[[class]]` entry in the same config. ### `encode.RunSynthesis` Signature Change Is a Breaking API Change `encode.RunSynthesis(snapshots []classify.WindowSnapshot, outputPath string)` will need to accept the config. If any external code (tests, future callers) uses this signature, they will break. Keep the change to a single place and update all call sites in the same commit. --- ## Anti-Patterns to Avoid ### Anti-Pattern: Reading Global `ClassFreqConfigs` from Multiple Places If `NewBank`, `encode.RunSynthesis`, and config loading all reference the package-level `synth.ClassFreqConfigs`, the merge point becomes ambiguous. The fix (Step 3 above) centralizes config reading to one place: `config.DefaultConfig()` reads from `ClassFreqConfigs` once when building defaults; everything downstream receives the already-merged map. ### Anti-Pattern: Storing `Waveform` as a String Everywhere Keeping `waveform` as a `string` from TOML all the way into `Oscillator` means every advance call parses or switches on a string. Parse the string to a `Waveform` int type at config-load time. The `Oscillator` field should be a typed `Waveform`, not `string`. ### Anti-Pattern: User Rules Appended After DefaultRules User rules must **prepend** `DefaultRules`, not append. `DefaultRules` ends with catch-all rules (`DstPort: 0`) that match any TCP or UDP packet. Appending user rules after these catch-alls means they will never be reached. --- ## Sources - Direct code inspection: `synth/config.go`, `synth/oscillator.go`, `synth/bank.go`, `synth/layer.go`, `classify/classifier.go`, `classify/rules.go`, `classify/types.go`, `encode/mp3.go`, `cmd/netsynth/main.go` — HIGH confidence - BurntSushi/toml usage in production Go projects (Hugo, dep): MEDIUM confidence (well-known in Go ecosystem) - Phase-accumulator waveform synthesis formulas (square, sawtooth, triangle): HIGH confidence (standard DSP, textbook formulas) --- *Architecture research for: NetSynth v1.1 — custom sound mappings integration* *Researched: 2026-03-26*