Files
yoloyolo/.planning/research/ARCHITECTURE.md
T
2026-03-26 16:54:31 +01:00

377 lines
16 KiB
Markdown

# 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*