1 Commits
Author SHA1 Message Date
gurixandClaude Opus 4.6 6b2db48339 feat(synth): add LFO modulation, ADSR envelopes, pentatonic tuning, and soft limiter
Replace static EMA-smoothed drones with an evolving ambient soundscape:
- ADSR envelope system with sustained (2s attack, 4s release) and bursty
  (30ms attack, no sustain) modes per protocol group
- LFO pitch wobble and amplitude tremolo with incommensurable rates per
  group (Eno technique) so modulation patterns never repeat
- C major pentatonic frequency tuning (just intonation) — any combination
  of active protocols sounds consonant
- tanh soft limiter on master output prevents clipping
- Sync all documentation: README, PROJECT.md, ARCHITECTURE.md, v1.2
  requirements traceability

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 19:38:58 +01:00
14 changed files with 824 additions and 255 deletions
+13 -7
View File
@@ -10,7 +10,7 @@ Network traffic patterns are instantly recognizable as distinct sounds — a pin
## Current State ## Current State
**v1.2 shipped.** 35 built-in traffic classes across 9 protocol families (Infrastructure, Web, Mail, Remote Access, File Transfer, Database, Discovery, VoIP, Unknown). Major-second frequency ladder (65-3250 Hz) with family-coherent sound design. Group-ordered `--print-config` with section headers. `[groups]` TOML config for reassigning protocols to different sound families. ~6,000 lines of Go across 7 packages, full test suite green. **v1.2 shipped.** 35 built-in traffic classes across 9 protocol families (Infrastructure, Web, Mail, Remote Access, File Transfer, Database, Discovery, VoIP, Unknown). C major pentatonic frequency tuning (just intonation, 65-6534 Hz) with ADSR envelope shaping (sustained vs bursty modes), LFO pitch/tremolo modulation (Eno technique with incommensurable rates), and tanh soft limiter. Group-ordered `--print-config` with section headers. `[groups]` TOML config for reassigning protocols to different sound families. ~6,500 lines of Go across 7 packages, full test suite green.
Tech stack: gopacket/gopacket v1.5.0, packetcap/go-pcap (pure Go capture), sjzar/go-lame v0.0.9 (embedded LAME), spf13/cobra v1.10.2, BurntSushi/toml v1.6.0. Tech stack: gopacket/gopacket v1.5.0, packetcap/go-pcap (pure Go capture), sjzar/go-lame v0.0.9 (embedded LAME), spf13/cobra v1.10.2, BurntSushi/toml v1.6.0.
@@ -25,8 +25,8 @@ All v1.0 + v1.1 + v1.2 requirements validated (45 total). Full pipeline: capture
- Auto-cluster unrecognized traffic into 4 hash-bucketed unknown classes with distinct tones - Auto-cluster unrecognized traffic into 4 hash-bucketed unknown classes with distinct tones
- Aggregate traffic into 500ms time windows driving amplitude evolution - Aggregate traffic into 500ms time windows driving amplitude evolution
- End-to-end pipeline: capture -> classify -> synthesize -> MP3 output - End-to-end pipeline: capture -> classify -> synthesize -> MP3 output
- Map each traffic class to a distinct ambient/drone layer (sine oscillators + EMA smoothing) - Map each traffic class to a distinct ambient layer with ADSR envelopes, LFO modulation, and pentatonic tuning
- Stereo mixing with constant-power panning, no distortion - Stereo mixing with constant-power panning and tanh soft limiting
- MP3 encoding via embedded LAME, zero-packet guard - MP3 encoding via embedded LAME, zero-packet guard
- CLI with `-i`, `-o`, `--list-interfaces`, `--verbose`, `--filter`, `--read` flags - CLI with `-i`, `-o`, `--list-interfaces`, `--verbose`, `--filter`, `--read` flags
- BPF capture filter for scoping live traffic - BPF capture filter for scoping live traffic
@@ -49,8 +49,11 @@ All v1.0 + v1.1 + v1.2 requirements validated (45 total). Full pipeline: capture
- Removed stale constants and future-proofed test bounds for extensibility - Removed stale constants and future-proofed test bounds for extensibility
- 21 new protocol classifications: Mail (IMAP, POP3, SMTP-sub), File Transfer (FTP, SMB, TFTP), Remote Access (RDP, Telnet, VNC), Database (MySQL, PostgreSQL, Redis, MongoDB), Discovery (mDNS, SSDP, SNMP), VoIP (SIP), Web (QUIC/HTTP3), Infrastructure (LDAP, Kerberos, Syslog) - 21 new protocol classifications: Mail (IMAP, POP3, SMTP-sub), File Transfer (FTP, SMB, TFTP), Remote Access (RDP, Telnet, VNC), Database (MySQL, PostgreSQL, Redis, MongoDB), Discovery (mDNS, SSDP, SNMP), VoIP (SIP), Web (QUIC/HTTP3), Infrastructure (LDAP, Kerberos, Syslog)
- No regression in existing 14 protocol classifications - No regression in existing 14 protocol classifications
- Major-second frequency ladder with family-coherent waveforms and group field on FreqConfig - C major pentatonic frequency tuning (just intonation) with family-coherent waveforms and group field on FreqConfig
- Auto-assign frequency range moved to 2500-4000 Hz (collision-free with built-ins) - ADSR envelope system: sustained protocols (2s attack, 4s release) and bursty protocols (30ms attack, no sustain)
- LFO modulation: per-group incommensurable pitch wobble and amplitude tremolo (Eno technique)
- Tanh soft limiter on master output prevents clipping
- Auto-assign frequency range moved to 5000-8000 Hz (collision-free with built-ins)
- Group-ordered `--print-config` output with section headers - Group-ordered `--print-config` output with section headers
- `[groups]` TOML config for protocol-to-group reassignment - `[groups]` TOML config for protocol-to-group reassignment
@@ -62,7 +65,7 @@ All v1.0 + v1.1 + v1.2 requirements validated (45 total). Full pipeline: capture
- Real-time audio playback — file output only - Real-time audio playback — file output only
- GUI or web interface — CLI only - GUI or web interface — CLI only
- Rhythmic/percussive output — ambient/drone style only - Fully rhythmic/beat-based output — ambient style with percussive accents for bursty protocols
- Stereo position configuration — add in future if requested - Stereo position configuration — add in future if requested
## Context ## Context
@@ -93,7 +96,10 @@ All v1.0 + v1.1 + v1.2 requirements validated (45 total). Full pipeline: capture
| go-lame (embedded C) over shine-mp3 | Better quality, smaller files, acceptable CGo tradeoff | Good | | go-lame (embedded C) over shine-mp3 | Better quality, smaller files, acceptable CGo tradeoff | Good |
| Hand-rolled synthesis over audio libraries | 20 lines of oscillator code, no unnecessary dependencies | Good | | Hand-rolled synthesis over audio libraries | 20 lines of oscillator code, no unnecessary dependencies | Good |
| Hash-bucketed unknowns over k-means | Deterministic, zero-config, sufficient for v1 audio distinction | Good | | Hash-bucketed unknowns over k-means | Deterministic, zero-config, sufficient for v1 audio distinction | Good |
| Major-second frequency ladder | Musical interval spacing prevents critical band masking between family members | Good | | C major pentatonic tuning (just intonation) | Any combination of active tones sounds consonant; replaced major-second ladder | Good |
| ADSR envelopes over pure EMA | Sustained flows get ambient pads; bursty protocols get percussive accents | Good |
| LFO with incommensurable rates | Eno technique ensures soundscape never repeats; each group has unique modulation | Good |
| Tanh soft limiter over hard clipping | Preserves dynamics while preventing distortion during traffic spikes | Good |
| Group field as string (not enum) | Extensible for new family names without code changes | Good | | Group field as string (not enum) | Extensible for new family names without code changes | Good |
| Ordered []Rule classifier over switch | Configurable, extensible, first-match-wins semantics | Good | | Ordered []Rule classifier over switch | Configurable, extensible, first-match-wins semantics | Good |
| 500ms window duration | Balances temporal resolution against snapshot frequency for synthesis | Good | | 500ms window duration | Balances temporal resolution against snapshot frequency for synthesis | Good |
+10 -10
View File
@@ -34,17 +34,17 @@ Requirements for extended protocol coverage milestone. Each maps to roadmap phas
### Frequency Design ### Frequency Design
- [ ] **FREQ-01**: All traffic classes (existing + new) are redistributed into group-coherent frequency bands using musical interval ratios (not fixed Hz steps) - [x] **FREQ-01**: All traffic classes (existing + new) are redistributed into group-coherent frequency bands using musical interval ratios (not fixed Hz steps)
- [ ] **FREQ-02**: Within-family protocols are separated by at least a major second interval (ratio 1.122) to avoid critical band masking - [x] **FREQ-02**: Within-family protocols are separated by at least a major second interval (ratio 1.122) to avoid critical band masking
- [ ] **FREQ-03**: The full frequency allocation table is designed and documented before any FreqConfig code is written - [x] **FREQ-03**: The full frequency allocation table is designed and documented before any FreqConfig code is written
- [x] **FREQ-04**: Auto-assign frequency range for user custom classes is updated to avoid collision with new built-in frequencies - [x] **FREQ-04**: Auto-assign frequency range for user custom classes is updated to avoid collision with new built-in frequencies
### Group System ### Group System
- [ ] **GRP-01**: Each traffic class belongs to a named group (Infrastructure, Web, Mail, File Transfer, Remote Access, Database, Discovery, VoIP) - [x] **GRP-01**: Each traffic class belongs to a named group (Infrastructure, Web, Mail, File Transfer, Remote Access, Database, Discovery, VoIP)
- [x] **GRP-02**: `--print-config` output organizes classes by group with section headers - [x] **GRP-02**: `--print-config` output organizes classes by group with section headers
- [x] **GRP-03**: Users can define `[groups]` in TOML config to reassign protocols to different sound families - [x] **GRP-03**: Users can define `[groups]` in TOML config to reassign protocols to different sound families
- [ ] **GRP-04**: FreqConfig gains a `Group` field that drives group-aware frequency allocation and config output - [x] **GRP-04**: FreqConfig gains a `Group` field that drives group-aware frequency allocation and config output
## Future Requirements ## Future Requirements
@@ -77,12 +77,12 @@ Which phases cover which requirements. Updated during roadmap creation.
| Requirement | Phase | Status | | Requirement | Phase | Status |
|-------------|-------|--------| |-------------|-------|--------|
| CLEAN-01 | Phase 8 | Complete | | CLEAN-01 | Phase 8 | Complete |
| FREQ-01 | Phase 9 | Pending | | FREQ-01 | Phase 9 | Complete |
| FREQ-02 | Phase 9 | Pending | | FREQ-02 | Phase 9 | Complete |
| FREQ-03 | Phase 9 | Pending | | FREQ-03 | Phase 9 | Complete |
| FREQ-04 | Phase 9 | Complete | | FREQ-04 | Phase 9 | Complete |
| GRP-01 | Phase 9 | Pending | | GRP-01 | Phase 9 | Complete |
| GRP-04 | Phase 9 | Pending | | GRP-04 | Phase 9 | Complete |
| PROTO-01 | Phase 10 | Complete | | PROTO-01 | Phase 10 | Complete |
| PROTO-02 | Phase 10 | Complete | | PROTO-02 | Phase 10 | Complete |
| PROTO-03 | Phase 10 | Complete | | PROTO-03 | Phase 10 | Complete |
+70 -5
View File
@@ -471,20 +471,85 @@ Frequency assignments must be validated by ear, not just by looking at Hz values
| Frequency collisions | None | Possible if not planned | | Frequency collisions | None | Possible if not planned |
| AllClasses() iteration | 14 items, trivial | 32 items, still trivial | | AllClasses() iteration | 14 items, trivial | 32 items, still trivial |
| PrintConfig output lines | ~60 lines | ~130 lines | | PrintConfig output lines | ~60 lines | ~130 lines |
| EMA smoothing convergence | No change | No change (per-layer, independent) | | ADSR envelope convergence | No change | No change (per-layer, independent) |
| NewBank construction time | Negligible | Negligible | | NewBank construction time | Negligible | Negligible |
The main practical change is perceptual loudness: with 32 layers each at 3.1% gain, the ambient mix becomes quieter when many protocols are active simultaneously. This is acceptable and matches the ambient/drone aesthetic. The `WhisperFloor` mechanism ensures inactive layers contribute minimally, so sessions with only 5-6 active protocols still sound full. The main practical change is perceptual loudness: with 32 layers each at ~2.6% gain (accounting for tremolo headroom), the ambient mix becomes quieter when many protocols are active simultaneously. This is acceptable and matches the ambient aesthetic. The ADSR envelope system ensures inactive layers fade to silence, while the soft limiter prevents distortion when many protocols spike simultaneously.
---
## Synthesis Layer: Envelope + LFO + Soft Limiter (post-v1.2)
The original synthesis pipeline used pure EMA (exponential moving average) smoothing and static oscillators, producing continuous drones. This was replaced with a richer signal chain to create an evolving ambient soundscape:
### Signal Chain per Layer
```
Oscillator → ADSR Envelope → LFO Modulation → Per-layer Gain → Stereo Pan → Soft Limiter → Mix
```
### ADSR Envelope System (`synth/envelope.go`)
Replaces the single-coefficient EMA with a four-stage envelope state machine:
| Parameter | Sustained Protocols | Bursty Protocols |
|-----------|--------------------|--------------------|
| Attack | 2.0s (slow fade-in) | 0.03s (fast onset) |
| Decay | 1.0s (slight dip) | 0.3s (rapid drop) |
| Sustain | 85% of peak | 0% (no sustain) |
| Release | 4.0s (long tail) | 1.5s (medium tail) |
Protocol categorization via `FreqConfig.Bursty` flag:
- **Sustained** (Bursty=false): HTTPS, HTTP, QUIC, SSH, SMTP, IMAP, databases — produce ambient pads
- **Bursty** (Bursty=true): ICMP, DNS, NTP, DHCP, mDNS, SSDP, SNMP, LDAP, Kerberos, Syslog — produce percussive accents
The envelope wraps traffic-rate EMA: during Sustain phase, the EMA-smoothed traffic rate modulates amplitude within the sustain level, preserving the original volume-follows-traffic behavior.
### LFO Modulation (`synth/lfo.go`)
Each Layer has two LFOs:
1. **Pitch LFO** — Modulates oscillator frequency by +/- 0.05-0.15 semitones. Creates subtle detuning that makes drones "breathe".
2. **Tremolo LFO** — Modulates amplitude by +/- 8-20%. Creates gentle pulsing.
Group-specific LFO rates use **incommensurable periods** (Eno technique) so combined modulation never repeats:
| Group | Pitch Rate (Hz) | Tremolo Rate (Hz) |
|-------|----------------|-------------------|
| Infrastructure | 0.031 | 0.053 |
| Web | 0.043 | 0.071 |
| Mail | 0.037 | 0.059 |
| Remote Access | 0.029 | 0.047 |
| File Transfer | 0.041 | 0.067 |
| Database | 0.023 | 0.083 |
| VoIP | 0.019 | 0.091 |
| Unknown | 0.053 | 0.037 |
### Pentatonic Frequency Tuning
The original major-second ladder was replaced with **C major pentatonic (just intonation)**: C D E G A across octaves 2-8 (ratios 1/1, 9/8, 5/4, 3/2, 5/3). This guarantees that any subset of simultaneously active protocols produces consonant intervals — no dissonant beating.
### Soft Limiter (`synth/bank.go`)
A `tanh`-based soft limiter on the master stereo output replaces hard clipping. Combined with tremolo-headroom-aware gain (`1 / (N × 1.2)`), this preserves dynamics while preventing distortion during traffic spikes.
### Integration Points
- `FreqConfig.Bursty` (config.go) → selects `SustainedEnvParams` vs `BurstyEnvParams` in `NewLayer()`
- `FreqConfig.Group` (config.go) selects `LFOConfig` via `LFOConfigForGroup()` in `NewLayer()`
- `Layer.AdvanceSample()` (layer.go) → applies pitch LFO to oscillator freq, generates sample, applies envelope, applies tremolo
- `OscillatorBank.RenderWindow()` (bank.go) → applies per-layer gain + pan, then `softLimit()` on each stereo frame
--- ---
## Sources ## Sources
- Direct code inspection: `synth/config.go`, `synth/bank.go`, `synth/layer.go`, `synth/oscillator.go`, `classify/types.go`, `classify/rules.go`, `classify/classifier.go`, `config/config.go`, `encode/mp3.go`, `cmd/netsynth/main.go` — HIGH confidence - Direct code inspection: `synth/config.go`, `synth/bank.go`, `synth/layer.go`, `synth/oscillator.go`, `synth/lfo.go`, `synth/envelope.go`, `classify/types.go`, `classify/rules.go`, `classify/classifier.go`, `config/config.go`, `encode/mp3.go`, `cmd/netsynth/main.go` — HIGH confidence
- Musical interval theory (detuning, harmonic relationships): HIGH confidence — standard acoustic physics - Musical interval theory (detuning, harmonic relationships, pentatonic scales): HIGH confidence — standard acoustic physics
- Brian Eno incommensurable-period technique: HIGH confidence — well-documented generative music principle
- v1.2 protocol list: determined from feature research (see FEATURES.md for rationale on which protocols to include) - v1.2 protocol list: determined from feature research (see FEATURES.md for rationale on which protocols to include)
--- ---
*Architecture research for: NetSynth v1.2 — extended protocol coverage with grouped families* *Architecture research for: NetSynth v1.2 — extended protocol coverage with grouped families*
*Researched: 2026-03-27* *Updated: 2026-03-27 — added synthesis layer documentation (ADSR, LFO, pentatonic, soft limiter)*
+22 -14
View File
@@ -121,7 +121,7 @@ If no config is found, NetSynth starts silently with built-in defaults.
# Only the fields you set are changed — everything else keeps its default. # Only the fields you set are changed — everything else keeps its default.
[sounds.ICMP] [sounds.ICMP]
frequency = 80.0 # Hz (default: 65.0) frequency = 80.0 # Hz (default: 65.4)
waveform = "triangle" # sine, square, sawtooth, or triangle waveform = "triangle" # sine, square, sawtooth, or triangle
[sounds.HTTPS] [sounds.HTTPS]
@@ -231,25 +231,33 @@ Capture -> Classify -> Aggregate -> Synthesize -> MP3
3. **Aggregate** — Classified packets are grouped into 500ms time windows. Each window records per-protocol packet counts that drive synthesis amplitudes. 3. **Aggregate** — Classified packets are grouped into 500ms time windows. Each window records per-protocol packet counts that drive synthesis amplitudes.
4. **Synthesize & Encode** — Each traffic class maps to an oscillator at a specific frequency, waveform, and stereo position. Amplitudes rise and fall via EMA smoothing based on traffic volume. All layers are mixed and encoded to MP3 via [LAME](https://github.com/sjzar/go-lame). 4. **Synthesize & Encode** — Each traffic class maps to an oscillator with ADSR envelope shaping, LFO modulation, and stereo positioning. Sustained protocols (HTTPS, SSH) fade in/out over seconds; bursty protocols (DNS, ICMP) produce short percussive accents. All layers are soft-limited and encoded to MP3 via [LAME](https://github.com/sjzar/go-lame).
### Sound Design ### Sound Design
Protocols are grouped into families that share a waveform type and frequency register, making related traffic sound cohesive while remaining distinguishable. Frequencies follow a major-second ladder within each family. Protocols are grouped into families that share a waveform type and frequency register, making related traffic sound cohesive while remaining distinguishable. Frequencies are tuned to a **C major pentatonic scale** (just intonation) so that any combination of simultaneously active protocols sounds harmonically consonant.
| Family | Waveform | Frequency Range | Protocols | | Family | Waveform | Frequency Range | Protocols |
|--------|----------|----------------|-----------| |--------|----------|----------------|-----------|
| Infrastructure | Triangle | 65-133 Hz | ICMP, NTP, DHCP, mDNS, SSDP, SNMP, DNS | | Infrastructure | Triangle | 65-147 Hz (C2-D3) | ICMP, NTP, DHCP, mDNS, SSDP, SNMP, DNS |
| Web | Sawtooth | 150-190 Hz | HTTPS, HTTP, QUIC | | Web | Sawtooth | 164-218 Hz (E3-A3) | HTTPS, HTTP, QUIC |
| Mail | Triangle | 214-305 Hz | SMTP, IMAP, POP3, SMTP-Sub | | Mail | Triangle | 262-392 Hz (C4-G4) | SMTP, IMAP, POP3, SMTP-Sub |
| Remote Access | Square | 343-485 Hz | SSH, RDP, Telnet, VNC | | Remote Access | Square | 436-654 Hz (A4-E5) | SSH, RDP, Telnet, VNC |
| File Transfer | Square | 545-687 Hz | FTP, SMB, TFTP | | File Transfer | Square | 784-1047 Hz (G5-C6) | FTP, SMB, TFTP |
| Unknown | Custom | 771-1375 Hz | Unknown1-4, OtherTCP, OtherUDP | | Unknown | Custom | 1175-2349 Hz (D6-D7) | Unknown1-4, OtherTCP, OtherUDP |
| Database | Sawtooth | 1543-2182 Hz | MySQL, PostgreSQL, Redis, MongoDB | | Database | Sawtooth | 2616-4186 Hz (E7-C8) | MySQL, PostgreSQL, Redis, MongoDB |
| VoIP | Sine | 2449 Hz | SIP | | VoIP | Sine | 4704 Hz (D8) | SIP |
| Infrastructure (ext.) | Triangle | 2950-3250 Hz | LDAP, Kerberos, Syslog | | Infrastructure (ext.) | Triangle | 5232-6534 Hz | LDAP, Kerberos, Syslog |
Sustained traffic sounds louder; quiet periods fade to silence. The result is a unique audio fingerprint of your network activity. All frequencies, waveforms, and group assignments can be overridden via the [config file](#custom-sound-configuration). #### Synthesis Features
- **ADSR envelopes** — Sustained protocols (HTTPS, SSH, streaming) fade in over 2 seconds and release over 4 seconds, creating ambient pads. Bursty protocols (DNS, ICMP, NTP) have a fast 30ms attack with no sustain, producing percussive pluck-like accents on each burst.
- **LFO modulation** — Each protocol group has unique, incommensurable pitch and tremolo LFO rates (Eno technique). Pitch wobbles by a few cents; amplitude pulses gently. The combined modulation pattern never repeats, keeping the soundscape evolving.
- **Soft limiter** — A tanh-based soft limiter on the master output prevents harsh clipping during traffic spikes while preserving dynamic range.
- **Bandlimited additive synthesis** — All waveforms (sine, square, sawtooth, triangle) use harmonics below Nyquist to prevent aliasing.
- **Constant-power stereo panning** — Bass frequencies center, mid-range spreads, higher frequencies pan wider.
The result is an evolving ambient soundscape — not static drones. A typical browsing session produces warm, breathing HTTPS pads with percussive DNS plucks on page loads and gentle ICMP pulses as a periodic heartbeat. All frequencies, waveforms, and group assignments can be overridden via the [config file](#custom-sound-configuration).
## Project Structure ## Project Structure
@@ -258,7 +266,7 @@ cmd/netsynth/ CLI entry point (Cobra)
capture/ Packet capture, BPF validation, pcap file reading capture/ Packet capture, BPF validation, pcap file reading
classify/ Protocol classification rules and types classify/ Protocol classification rules and types
aggregate/ Time-window aggregation and summary output aggregate/ Time-window aggregation and summary output
synth/ Oscillators, waveforms, EMA layers, stereo mixer, tone bank synth/ Oscillators, waveforms, ADSR envelopes, LFO modulation, stereo mixer, tone bank
config/ TOML config loading, validation, and partial merge config/ TOML config loading, validation, and partial merge
encode/ MP3 encoding via embedded LAME encode/ MP3 encoding via embedded LAME
``` ```
+4 -4
View File
@@ -67,8 +67,8 @@ func TestLoadPartialOverrideWaveform(t *testing.T) {
t.Errorf("ICMP WaveformType: got %v, want WaveformSquare", cfgs[classify.ClassICMP].WaveformType) t.Errorf("ICMP WaveformType: got %v, want WaveformSquare", cfgs[classify.ClassICMP].WaveformType)
} }
// BaseHz should be unchanged (default is 65.0) // BaseHz should be unchanged (default is 65.0)
if cfgs[classify.ClassICMP].BaseHz != 65.0 { if cfgs[classify.ClassICMP].BaseHz != 65.4 {
t.Errorf("ICMP BaseHz: got %v, want 65.0 (default)", cfgs[classify.ClassICMP].BaseHz) t.Errorf("ICMP BaseHz: got %v, want 65.4 (default)", cfgs[classify.ClassICMP].BaseHz)
} }
// Harmonics should be regenerated (non-empty) // Harmonics should be regenerated (non-empty)
if len(cfgs[classify.ClassICMP].Harmonics) == 0 { if len(cfgs[classify.ClassICMP].Harmonics) == 0 {
@@ -121,8 +121,8 @@ func TestLoadNoConfig(t *testing.T) {
t.Errorf("result map size: got %d, want %d", len(cfgs), len(classify.AllClasses())) t.Errorf("result map size: got %d, want %d", len(cfgs), len(classify.AllClasses()))
} }
// ICMP should be at its default BaseHz (65.0) // ICMP should be at its default BaseHz (65.0)
if cfgs[classify.ClassICMP].BaseHz != 65.0 { if cfgs[classify.ClassICMP].BaseHz != 65.4 {
t.Errorf("ICMP BaseHz: got %v, want 65.0 (default)", cfgs[classify.ClassICMP].BaseHz) t.Errorf("ICMP BaseHz: got %v, want 65.4 (default)", cfgs[classify.ClassICMP].BaseHz)
} }
} }
+19 -6
View File
@@ -1,6 +1,10 @@
package synth package synth
import "github.com/netsynth/netsynth/classify" import (
"math"
"github.com/netsynth/netsynth/classify"
)
// OscillatorBank holds synthesis layers, one per TrafficClass in the injected config map. // OscillatorBank holds synthesis layers, one per TrafficClass in the injected config map.
// It consumes WindowSnapshot data and renders stereo PCM frames. // It consumes WindowSnapshot data and renders stereo PCM frames.
@@ -10,15 +14,18 @@ type OscillatorBank struct {
gainPerLayer float64 gainPerLayer float64
} }
// maxTremoloDepth is the highest tremolo depth across all groups.
// Used to compute headroom so tremolo doesn't cause clipping.
const maxTremoloDepth = 0.20
// NewBank creates an OscillatorBank with one Layer per entry in cfgs. // NewBank creates an OscillatorBank with one Layer per entry in cfgs.
// tau is the EMA time constant in seconds (use 1.0 for D-07's "1-2 second" feel). // tau is the EMA time constant in seconds (use 1.0 for D-07's "1-2 second" feel).
// gainPerLayer is computed dynamically as 1/len(cfgs) so that all layers at max // gainPerLayer accounts for tremolo headroom: 1 / (N * (1 + maxTremoloDepth)).
// amplitude sum to exactly 1.0 (no clipping), regardless of how many classes are active.
func NewBank(tau float64, cfgs map[classify.TrafficClass]FreqConfig) *OscillatorBank { func NewBank(tau float64, cfgs map[classify.TrafficClass]FreqConfig) *OscillatorBank {
b := &OscillatorBank{ b := &OscillatorBank{
layers: make(map[classify.TrafficClass]*Layer, len(cfgs)), layers: make(map[classify.TrafficClass]*Layer, len(cfgs)),
tau: tau, tau: tau,
gainPerLayer: 1.0 / float64(len(cfgs)), gainPerLayer: 1.0 / (float64(len(cfgs)) * (1.0 + maxTremoloDepth)),
} }
for class, cfg := range cfgs { for class, cfg := range cfgs {
b.layers[class] = NewLayer(cfg, SampleRate, tau) b.layers[class] = NewLayer(cfg, SampleRate, tau)
@@ -28,7 +35,7 @@ func NewBank(tau float64, cfgs map[classify.TrafficClass]FreqConfig) *Oscillator
// RenderWindow updates amplitude targets from snap, then renders SamplesPerWindow // RenderWindow updates amplitude targets from snap, then renders SamplesPerWindow
// stereo frames. Each frame is [2]float64{left, right} with values in [-1, 1]. // stereo frames. Each frame is [2]float64{left, right} with values in [-1, 1].
// Each layer gets 1/N of the total gain where N is the number of layers. // Each layer gets gain with tremolo headroom. A soft limiter prevents any residual clipping.
func (b *OscillatorBank) RenderWindow(snap classify.WindowSnapshot) [][2]float64 { func (b *OscillatorBank) RenderWindow(snap classify.WindowSnapshot) [][2]float64 {
// Find max count for normalization // Find max count for normalization
var maxCount int64 var maxCount int64
@@ -54,7 +61,13 @@ func (b *OscillatorBank) RenderWindow(snap classify.WindowSnapshot) [][2]float64
sumL += sample * b.gainPerLayer * gainL sumL += sample * b.gainPerLayer * gainL
sumR += sample * b.gainPerLayer * gainR sumR += sample * b.gainPerLayer * gainR
} }
frames[i] = [2]float64{sumL, sumR} frames[i] = [2]float64{softLimit(sumL), softLimit(sumR)}
} }
return frames return frames
} }
// softLimit applies a tanh-based soft limiter to prevent clipping.
// Values within [-0.9, 0.9] pass nearly linearly; beyond that, they compress smoothly.
func softLimit(x float64) float64 {
return math.Tanh(x)
}
+9 -9
View File
@@ -129,24 +129,24 @@ func TestStereoPan(t *testing.T) {
} }
} }
func TestMultipleWindowsEMAConvergence(t *testing.T) { func TestMultipleWindowsEnvelopeConvergence(t *testing.T) {
b := NewBank(1.0, ClassFreqConfigs) b := NewBank(1.0, ClassFreqConfigs)
counts := make(map[classify.TrafficClass]int64) counts := make(map[classify.TrafficClass]int64)
counts[classify.ClassICMP] = 100 counts[classify.ClassHTTPS] = 100 // sustained protocol — slow attack
snap := classify.WindowSnapshot{ snap := classify.WindowSnapshot{
Counts: counts, Counts: counts,
TotalPackets: 100, TotalPackets: 100,
WindowIndex: 0, WindowIndex: 0,
} }
// Compute RMS for first and last window render // Compute RMS for first window render
rmsFirst := windowRMS(b.RenderWindow(snap)) rmsFirst := windowRMS(b.RenderWindow(snap))
// Render 4 more windows with the same snapshot // Render more windows to let ADSR attack build up (2s attack at 500ms/window ≈ 4 windows)
var rmsLast float64 var rmsLast float64
for i := 0; i < 4; i++ { for i := 0; i < 8; i++ {
rmsLast = windowRMS(b.RenderWindow(snap)) rmsLast = windowRMS(b.RenderWindow(snap))
} }
if rmsLast <= rmsFirst { if rmsLast <= rmsFirst {
t.Errorf("EMA should converge upward: rmsFirst=%v, rmsLast=%v", rmsFirst, rmsLast) t.Errorf("envelope should converge upward: rmsFirst=%v, rmsLast=%v", rmsFirst, rmsLast)
} }
} }
@@ -161,9 +161,9 @@ func TestNewBankDynamicGain(t *testing.T) {
if len(b.layers) != 3 { if len(b.layers) != 3 {
t.Errorf("NewBank with 3 configs has %d layers, want 3", len(b.layers)) t.Errorf("NewBank with 3 configs has %d layers, want 3", len(b.layers))
} }
// Verify gainPerLayer is 1/3 // Verify gainPerLayer accounts for tremolo headroom: 1/(3 * 1.2)
expected := 1.0 / 3.0 expected := 1.0 / (3.0 * (1.0 + maxTremoloDepth))
if b.gainPerLayer != expected { if math.Abs(b.gainPerLayer-expected) > 1e-12 {
t.Errorf("gainPerLayer = %v, want %v", b.gainPerLayer, expected) t.Errorf("gainPerLayer = %v, want %v", b.gainPerLayer, expected)
} }
} }
+146 -136
View File
@@ -69,302 +69,312 @@ type FreqConfig struct {
Pan float64 // [-1, 1]: -1=full left, 0=center, +1=full right Pan float64 // [-1, 1]: -1=full left, 0=center, +1=full right
WaveformType WaveformType // zero value WaveformCustom uses Harmonics as-is WaveformType WaveformType // zero value WaveformCustom uses Harmonics as-is
Group string // sound family: "Infrastructure", "Web", "Mail", "Remote Access", "Unknown", etc. Group string // sound family: "Infrastructure", "Web", "Mail", "Remote Access", "Unknown", etc.
Bursty bool // true for event-like protocols (DNS, ICMP, NTP) — fast attack, no sustain
} }
// Frequency Allocation Table (Phase 9 design — major-second ladder, 65-2449 Hz) // Frequency Allocation Table — C Major Pentatonic, Just Intonation
// //
// Slot Hz Class Group Waveform Pan // Scale: C D E G A across octaves 2-7 (ratios 1/1, 9/8, 5/4, 3/2, 5/3)
// 0 65 ICMP Infrastructure Triangle -0.3 // Any combination of active tones is consonant — no dissonant intervals possible.
// 1 73 NTP Infrastructure Triangle -0.1
// 2 82 DHCP Infrastructure Triangle 0.1
// 3 93 mDNS Infrastructure Triangle 0.3 (Phase 10)
// 4 105 SSDP Infrastructure Triangle -0.2 (Phase 10)
// 5 118 SNMP Infrastructure Triangle 0.2 (Phase 10)
// 6 133 DNS Infrastructure Triangle 0.0
// 7 150 HTTPS Web Sawtooth -0.4
// 8 169 HTTP Web Sawtooth -0.3
// 9 190 HTTP3 Web Sawtooth -0.2 (Phase 10)
// 10 214 SMTP Mail Triangle 0.2
// 11 241 IMAP Mail Triangle 0.3 (Phase 10)
// 12 271 POP3 Mail Triangle 0.4 (Phase 10)
// 13 305 SMTP-sub Mail Triangle 0.5 (Phase 10)
// 14 343 SSH Remote Access Square -0.7
// 15 385 RDP Remote Access Square -0.6 (Phase 10)
// 16 432 Telnet Remote Access Square -0.5 (Phase 10)
// 17 485 VNC Remote Access Square -0.4 (Phase 10)
// 18 545 FTP File Transfer Square 0.5 (Phase 10)
// 19 612 SMB File Transfer Square 0.6 (Phase 10)
// 20 687 TFTP File Transfer Square 0.7 (Phase 10)
// 21 771 unknown-1 Unknown Custom -0.9
// 22 866 unknown-2 Unknown Custom 0.9
// 23 972 unknown-3 Unknown Custom -0.7
// 24 1091 unknown-4 Unknown Custom 0.7
// 25 1225 other-TCP Unknown Custom -0.5
// 26 1375 other-UDP Unknown Custom 0.5
// 27 1543 MySQL Database Sawtooth -0.4 (Phase 10)
// 28 1732 PostgreSQL Database Sawtooth -0.2 (Phase 10)
// 29 1944 Redis Database Sawtooth 0.2 (Phase 10)
// 30 2182 MongoDB Database Sawtooth 0.4 (Phase 10)
// 31 2449 SIP VoIP Sine 0.0 (Phase 10)
// //
// Auto-assign range: [2500, 4000] Hz (see config/config.go) // Slot Note Hz Class Group Waveform Pan Bursty
// 0 C2 65.4 ICMP Infrastructure Triangle -0.3 yes
// 1 D2 73.6 NTP Infrastructure Triangle -0.1 yes
// 2 E2 81.8 DHCP Infrastructure Triangle 0.1 yes
// 3 G2 98.0 mDNS Infrastructure Triangle 0.3 yes
// 4 A2 109.0 SSDP Infrastructure Triangle -0.2 yes
// 5 C3 130.8 SNMP Infrastructure Triangle 0.2 yes
// 6 D3 146.8 DNS Infrastructure Triangle 0.0 yes
// 7 E3 163.5 HTTPS Web Sawtooth -0.4 no
// 8 G3 196.0 HTTP Web Sawtooth -0.3 no
// 9 A3 218.0 QUIC Web Sawtooth -0.2 no
// 10 C4 261.6 SMTP Mail Triangle 0.2 no
// 11 D4 293.7 IMAP Mail Triangle 0.3 no
// 12 E4 327.0 POP3 Mail Triangle 0.4 no
// 13 G4 392.0 SMTP-sub Mail Triangle 0.5 no
// 14 A4 436.0 SSH Remote Access Square -0.7 no
// 15 C5 523.3 RDP Remote Access Square -0.6 no
// 16 D5 587.3 Telnet Remote Access Square -0.5 no
// 17 E5 654.1 VNC Remote Access Square -0.4 no
// 18 G5 784.0 FTP File Transfer Square 0.5 no
// 19 A5 872.1 SMB File Transfer Square 0.6 no
// 20 C6 1046.5 TFTP File Transfer Square 0.7 no
// 21 D6 1174.7 unknown-1 Unknown Custom -0.9 no
// 22 E6 1308.1 unknown-2 Unknown Custom 0.9 no
// 23 G6 1568.0 unknown-3 Unknown Custom -0.7 no
// 24 A6 1744.2 unknown-4 Unknown Custom 0.7 no
// 25 C7 2093.0 other-TCP Unknown Custom -0.5 no
// 26 D7 2349.3 other-UDP Unknown Custom 0.5 no
// 27 E7 2616.1 MySQL Database Sawtooth -0.4 no
// 28 G7 3136.0 PostgreSQL Database Sawtooth -0.2 no
// 29 A7 3488.4 Redis Database Sawtooth 0.2 no
// 30 C8 4186.0 MongoDB Database Sawtooth 0.4 no
// 31 D8 4704.0 SIP VoIP Sine 0.0 no
//
// Auto-assign range: [5000, 8000] Hz (see config/config.go)
// LDAP, Kerberos, Syslog: assigned to pentatonic slots in octave 3 (infrastructure)
// ClassFreqConfigs maps each traffic class to its synthesis parameters. // ClassFreqConfigs maps each traffic class to its synthesis parameters.
// Frequencies per Phase 9 major-second ladder design. Harmonics per D-05/D-06. // Frequencies: C Major Pentatonic (just intonation) across octaves 2-8.
// Pan positions per D-12. Group field drives family-aware config output (GRP-04). // Pan positions per D-12. Group field drives family-aware config output (GRP-04).
var ClassFreqConfigs = map[classify.TrafficClass]FreqConfig{ var ClassFreqConfigs = map[classify.TrafficClass]FreqConfig{
// --- Infrastructure (Triangle, 65-133 Hz) --- // --- Infrastructure (Triangle, C2-D3, bursty) ---
classify.ClassICMP: { classify.ClassICMP: {
BaseHz: 65.0, BaseHz: 65.4, // C2
WaveformType: WaveformTriangle, WaveformType: WaveformTriangle,
Harmonics: WaveformPresetHarmonics(WaveformTriangle, 65.0, SampleRate), Harmonics: WaveformPresetHarmonics(WaveformTriangle, 65.4, SampleRate),
Pan: -0.3, Pan: -0.3,
Group: "Infrastructure", Group: "Infrastructure",
Bursty: true,
}, },
classify.ClassNTP: { classify.ClassNTP: {
BaseHz: 73.0, BaseHz: 73.6, // D2
WaveformType: WaveformTriangle, WaveformType: WaveformTriangle,
Harmonics: WaveformPresetHarmonics(WaveformTriangle, 73.0, SampleRate), Harmonics: WaveformPresetHarmonics(WaveformTriangle, 73.6, SampleRate),
Pan: -0.1, Pan: -0.1,
Group: "Infrastructure", Group: "Infrastructure",
Bursty: true,
}, },
classify.ClassDHCP: { classify.ClassDHCP: {
BaseHz: 82.0, BaseHz: 81.8, // E2
WaveformType: WaveformTriangle, WaveformType: WaveformTriangle,
Harmonics: WaveformPresetHarmonics(WaveformTriangle, 82.0, SampleRate), Harmonics: WaveformPresetHarmonics(WaveformTriangle, 81.8, SampleRate),
Pan: 0.1, Pan: 0.1,
Group: "Infrastructure", Group: "Infrastructure",
Bursty: true,
},
classify.ClassMDNS: {
BaseHz: 98.0, // G2
WaveformType: WaveformTriangle,
Harmonics: WaveformPresetHarmonics(WaveformTriangle, 98.0, SampleRate),
Pan: 0.3,
Group: "Infrastructure",
Bursty: true,
},
classify.ClassSSDP: {
BaseHz: 109.0, // A2
WaveformType: WaveformTriangle,
Harmonics: WaveformPresetHarmonics(WaveformTriangle, 109.0, SampleRate),
Pan: -0.2,
Group: "Infrastructure",
Bursty: true,
},
classify.ClassSNMP: {
BaseHz: 130.8, // C3
WaveformType: WaveformTriangle,
Harmonics: WaveformPresetHarmonics(WaveformTriangle, 130.8, SampleRate),
Pan: 0.2,
Group: "Infrastructure",
Bursty: true,
}, },
classify.ClassDNS: { classify.ClassDNS: {
BaseHz: 133.0, BaseHz: 146.8, // D3
WaveformType: WaveformTriangle, WaveformType: WaveformTriangle,
Harmonics: WaveformPresetHarmonics(WaveformTriangle, 133.0, SampleRate), Harmonics: WaveformPresetHarmonics(WaveformTriangle, 146.8, SampleRate),
Pan: 0.0, Pan: 0.0,
Group: "Infrastructure", Group: "Infrastructure",
Bursty: true,
}, },
// --- Web (Sawtooth, 150-169 Hz) --- // --- Web (Sawtooth, E3-A3, sustained) ---
classify.ClassHTTPS: { classify.ClassHTTPS: {
BaseHz: 150.0, BaseHz: 163.5, // E3
WaveformType: WaveformSawtooth, WaveformType: WaveformSawtooth,
Harmonics: WaveformPresetHarmonics(WaveformSawtooth, 150.0, SampleRate), Harmonics: WaveformPresetHarmonics(WaveformSawtooth, 163.5, SampleRate),
Pan: -0.4, Pan: -0.4,
Group: "Web", Group: "Web",
}, },
classify.ClassHTTP: { classify.ClassHTTP: {
BaseHz: 169.0, BaseHz: 196.0, // G3
WaveformType: WaveformSawtooth, WaveformType: WaveformSawtooth,
Harmonics: WaveformPresetHarmonics(WaveformSawtooth, 169.0, SampleRate), Harmonics: WaveformPresetHarmonics(WaveformSawtooth, 196.0, SampleRate),
Pan: -0.3, Pan: -0.3,
Group: "Web", Group: "Web",
}, },
// --- Mail (Triangle, 214 Hz) ---
classify.ClassSMTP: {
BaseHz: 214.0,
WaveformType: WaveformTriangle,
Harmonics: WaveformPresetHarmonics(WaveformTriangle, 214.0, SampleRate),
Pan: 0.2,
Group: "Mail",
},
// --- Infrastructure additions (Triangle, 93-118 Hz) ---
classify.ClassMDNS: {
BaseHz: 93.0,
WaveformType: WaveformTriangle,
Harmonics: WaveformPresetHarmonics(WaveformTriangle, 93.0, SampleRate),
Pan: 0.3,
Group: "Infrastructure",
},
classify.ClassSSDP: {
BaseHz: 105.0,
WaveformType: WaveformTriangle,
Harmonics: WaveformPresetHarmonics(WaveformTriangle, 105.0, SampleRate),
Pan: -0.2,
Group: "Infrastructure",
},
classify.ClassSNMP: {
BaseHz: 118.0,
WaveformType: WaveformTriangle,
Harmonics: WaveformPresetHarmonics(WaveformTriangle, 118.0, SampleRate),
Pan: 0.2,
Group: "Infrastructure",
},
// --- Web addition (Sawtooth, 190 Hz) ---
classify.ClassQUIC: { classify.ClassQUIC: {
BaseHz: 190.0, BaseHz: 218.0, // A3
WaveformType: WaveformSawtooth, WaveformType: WaveformSawtooth,
Harmonics: WaveformPresetHarmonics(WaveformSawtooth, 190.0, SampleRate), Harmonics: WaveformPresetHarmonics(WaveformSawtooth, 218.0, SampleRate),
Pan: -0.2, Pan: -0.2,
Group: "Web", Group: "Web",
}, },
// --- Mail additions (Triangle, 241-305 Hz) --- // --- Mail (Triangle, C4-G4) ---
classify.ClassIMAP: { classify.ClassSMTP: {
BaseHz: 241.0, BaseHz: 261.6, // C4
WaveformType: WaveformTriangle, WaveformType: WaveformTriangle,
Harmonics: WaveformPresetHarmonics(WaveformTriangle, 241.0, SampleRate), Harmonics: WaveformPresetHarmonics(WaveformTriangle, 261.6, SampleRate),
Pan: 0.2,
Group: "Mail",
},
classify.ClassIMAP: {
BaseHz: 293.7, // D4
WaveformType: WaveformTriangle,
Harmonics: WaveformPresetHarmonics(WaveformTriangle, 293.7, SampleRate),
Pan: 0.3, Pan: 0.3,
Group: "Mail", Group: "Mail",
}, },
classify.ClassPOP3: { classify.ClassPOP3: {
BaseHz: 271.0, BaseHz: 327.0, // E4
WaveformType: WaveformTriangle, WaveformType: WaveformTriangle,
Harmonics: WaveformPresetHarmonics(WaveformTriangle, 271.0, SampleRate), Harmonics: WaveformPresetHarmonics(WaveformTriangle, 327.0, SampleRate),
Pan: 0.4, Pan: 0.4,
Group: "Mail", Group: "Mail",
}, },
classify.ClassSMTPSub: { classify.ClassSMTPSub: {
BaseHz: 305.0, BaseHz: 392.0, // G4
WaveformType: WaveformTriangle, WaveformType: WaveformTriangle,
Harmonics: WaveformPresetHarmonics(WaveformTriangle, 305.0, SampleRate), Harmonics: WaveformPresetHarmonics(WaveformTriangle, 392.0, SampleRate),
Pan: 0.5, Pan: 0.5,
Group: "Mail", Group: "Mail",
}, },
// --- Remote Access (Square, 343 Hz) --- // --- Remote Access (Square, A4-E5) ---
classify.ClassSSH: { classify.ClassSSH: {
BaseHz: 343.0, BaseHz: 436.0, // A4
WaveformType: WaveformSquare, WaveformType: WaveformSquare,
Harmonics: WaveformPresetHarmonics(WaveformSquare, 343.0, SampleRate), Harmonics: WaveformPresetHarmonics(WaveformSquare, 436.0, SampleRate),
Pan: -0.7, Pan: -0.7,
Group: "Remote Access", Group: "Remote Access",
}, },
// --- Remote Access additions (Square, 385-485 Hz) ---
classify.ClassRDP: { classify.ClassRDP: {
BaseHz: 385.0, BaseHz: 523.3, // C5
WaveformType: WaveformSquare, WaveformType: WaveformSquare,
Harmonics: WaveformPresetHarmonics(WaveformSquare, 385.0, SampleRate), Harmonics: WaveformPresetHarmonics(WaveformSquare, 523.3, SampleRate),
Pan: -0.6, Pan: -0.6,
Group: "Remote Access", Group: "Remote Access",
}, },
classify.ClassTelnet: { classify.ClassTelnet: {
BaseHz: 432.0, BaseHz: 587.3, // D5
WaveformType: WaveformSquare, WaveformType: WaveformSquare,
Harmonics: WaveformPresetHarmonics(WaveformSquare, 432.0, SampleRate), Harmonics: WaveformPresetHarmonics(WaveformSquare, 587.3, SampleRate),
Pan: -0.5, Pan: -0.5,
Group: "Remote Access", Group: "Remote Access",
}, },
classify.ClassVNC: { classify.ClassVNC: {
BaseHz: 485.0, BaseHz: 654.1, // E5
WaveformType: WaveformSquare, WaveformType: WaveformSquare,
Harmonics: WaveformPresetHarmonics(WaveformSquare, 485.0, SampleRate), Harmonics: WaveformPresetHarmonics(WaveformSquare, 654.1, SampleRate),
Pan: -0.4, Pan: -0.4,
Group: "Remote Access", Group: "Remote Access",
}, },
// --- File Transfer additions (Square, 545-687 Hz) --- // --- File Transfer (Square, G5-C6) ---
classify.ClassFTP: { classify.ClassFTP: {
BaseHz: 545.0, BaseHz: 784.0, // G5
WaveformType: WaveformSquare, WaveformType: WaveformSquare,
Harmonics: WaveformPresetHarmonics(WaveformSquare, 545.0, SampleRate), Harmonics: WaveformPresetHarmonics(WaveformSquare, 784.0, SampleRate),
Pan: 0.5, Pan: 0.5,
Group: "File Transfer", Group: "File Transfer",
}, },
classify.ClassSMB: { classify.ClassSMB: {
BaseHz: 612.0, BaseHz: 872.1, // A5
WaveformType: WaveformSquare, WaveformType: WaveformSquare,
Harmonics: WaveformPresetHarmonics(WaveformSquare, 612.0, SampleRate), Harmonics: WaveformPresetHarmonics(WaveformSquare, 872.1, SampleRate),
Pan: 0.6, Pan: 0.6,
Group: "File Transfer", Group: "File Transfer",
}, },
classify.ClassTFTP: { classify.ClassTFTP: {
BaseHz: 687.0, BaseHz: 1046.5, // C6
WaveformType: WaveformSquare, WaveformType: WaveformSquare,
Harmonics: WaveformPresetHarmonics(WaveformSquare, 687.0, SampleRate), Harmonics: WaveformPresetHarmonics(WaveformSquare, 1046.5, SampleRate),
Pan: 0.7, Pan: 0.7,
Group: "File Transfer", Group: "File Transfer",
}, },
// --- Unknown (Custom harmonics, 771-1375 Hz) --- // --- Unknown (Custom harmonics, D6-D7) ---
// D-05/D-06: dissonant harmonic character {1,1.0},{2,0.8},{3,0.4} retained for all Unknown entries. // Dissonant harmonic character {1,1.0},{2,0.8},{3,0.4} retained for all Unknown entries.
// WaveformType is zero value (WaveformCustom) so bank.go uses the stored Harmonics directly.
classify.ClassUnknown1: { classify.ClassUnknown1: {
BaseHz: 771.0, BaseHz: 1174.7, // D6
Harmonics: []HarmonicDef{{1, 1.0}, {2, 0.8}, {3, 0.4}}, Harmonics: []HarmonicDef{{1, 1.0}, {2, 0.8}, {3, 0.4}},
Pan: -0.9, Pan: -0.9,
Group: "Unknown", Group: "Unknown",
}, },
classify.ClassUnknown2: { classify.ClassUnknown2: {
BaseHz: 866.0, BaseHz: 1308.1, // E6
Harmonics: []HarmonicDef{{1, 1.0}, {2, 0.8}, {3, 0.4}}, Harmonics: []HarmonicDef{{1, 1.0}, {2, 0.8}, {3, 0.4}},
Pan: 0.9, Pan: 0.9,
Group: "Unknown", Group: "Unknown",
}, },
classify.ClassUnknown3: { classify.ClassUnknown3: {
BaseHz: 972.0, BaseHz: 1568.0, // G6
Harmonics: []HarmonicDef{{1, 1.0}, {2, 0.8}, {3, 0.4}}, Harmonics: []HarmonicDef{{1, 1.0}, {2, 0.8}, {3, 0.4}},
Pan: -0.7, Pan: -0.7,
Group: "Unknown", Group: "Unknown",
}, },
classify.ClassUnknown4: { classify.ClassUnknown4: {
BaseHz: 1091.0, BaseHz: 1744.2, // A6
Harmonics: []HarmonicDef{{1, 1.0}, {2, 0.8}, {3, 0.4}}, Harmonics: []HarmonicDef{{1, 1.0}, {2, 0.8}, {3, 0.4}},
Pan: 0.7, Pan: 0.7,
Group: "Unknown", Group: "Unknown",
}, },
classify.ClassOtherTCP: { classify.ClassOtherTCP: {
BaseHz: 1225.0, BaseHz: 2093.0, // C7
Harmonics: []HarmonicDef{{1, 1.0}, {2, 0.8}, {3, 0.4}}, Harmonics: []HarmonicDef{{1, 1.0}, {2, 0.8}, {3, 0.4}},
Pan: -0.5, Pan: -0.5,
Group: "Unknown", Group: "Unknown",
}, },
classify.ClassOtherUDP: { classify.ClassOtherUDP: {
BaseHz: 1375.0, BaseHz: 2349.3, // D7
Harmonics: []HarmonicDef{{1, 1.0}, {2, 0.8}, {3, 0.4}}, Harmonics: []HarmonicDef{{1, 1.0}, {2, 0.8}, {3, 0.4}},
Pan: 0.5, Pan: 0.5,
Group: "Unknown", Group: "Unknown",
}, },
// --- Database additions (Sawtooth, 1543-2182 Hz) --- // --- Database (Sawtooth, E7-C8) ---
classify.ClassMySQL: { classify.ClassMySQL: {
BaseHz: 1543.0, BaseHz: 2616.1, // E7
WaveformType: WaveformSawtooth, WaveformType: WaveformSawtooth,
Harmonics: WaveformPresetHarmonics(WaveformSawtooth, 1543.0, SampleRate), Harmonics: WaveformPresetHarmonics(WaveformSawtooth, 2616.1, SampleRate),
Pan: -0.4, Pan: -0.4,
Group: "Database", Group: "Database",
}, },
classify.ClassPostgreSQL: { classify.ClassPostgreSQL: {
BaseHz: 1732.0, BaseHz: 3136.0, // G7
WaveformType: WaveformSawtooth, WaveformType: WaveformSawtooth,
Harmonics: WaveformPresetHarmonics(WaveformSawtooth, 1732.0, SampleRate), Harmonics: WaveformPresetHarmonics(WaveformSawtooth, 3136.0, SampleRate),
Pan: -0.2, Pan: -0.2,
Group: "Database", Group: "Database",
}, },
classify.ClassRedis: { classify.ClassRedis: {
BaseHz: 1944.0, BaseHz: 3488.4, // A7
WaveformType: WaveformSawtooth, WaveformType: WaveformSawtooth,
Harmonics: WaveformPresetHarmonics(WaveformSawtooth, 1944.0, SampleRate), Harmonics: WaveformPresetHarmonics(WaveformSawtooth, 3488.4, SampleRate),
Pan: 0.2, Pan: 0.2,
Group: "Database", Group: "Database",
}, },
classify.ClassMongoDB: { classify.ClassMongoDB: {
BaseHz: 2182.0, BaseHz: 4186.0, // C8
WaveformType: WaveformSawtooth, WaveformType: WaveformSawtooth,
Harmonics: WaveformPresetHarmonics(WaveformSawtooth, 2182.0, SampleRate), Harmonics: WaveformPresetHarmonics(WaveformSawtooth, 4186.0, SampleRate),
Pan: 0.4, Pan: 0.4,
Group: "Database", Group: "Database",
}, },
// --- VoIP (Sine, 2449 Hz) --- // --- VoIP (Sine, D8) ---
classify.ClassSIP: { classify.ClassSIP: {
BaseHz: 2449.0, BaseHz: 4704.0, // D8
WaveformType: WaveformSine, WaveformType: WaveformSine,
Harmonics: WaveformPresetHarmonics(WaveformSine, 2449.0, SampleRate), Harmonics: WaveformPresetHarmonics(WaveformSine, 4704.0, SampleRate),
Pan: 0.0, Pan: 0.0,
Group: "VoIP", Group: "VoIP",
}, },
// --- Infrastructure auto-assigned (Triangle, 2950-3250 Hz) per D-02 --- // --- Infrastructure auto-assigned (Triangle, pentatonic upper octaves) ---
classify.ClassLDAP: { classify.ClassLDAP: {
BaseHz: 2950.0, BaseHz: 5232.0, // E8 — auto-assign range
WaveformType: WaveformTriangle, WaveformType: WaveformTriangle,
Harmonics: WaveformPresetHarmonics(WaveformTriangle, 2950.0, SampleRate), Harmonics: WaveformPresetHarmonics(WaveformTriangle, 5232.0, SampleRate),
Pan: -0.2, Pan: -0.2,
Group: "Infrastructure", Group: "Infrastructure",
Bursty: true,
}, },
classify.ClassKerberos: { classify.ClassKerberos: {
BaseHz: 3250.0, BaseHz: 5878.0, // G8 (approx) — auto-assign range
WaveformType: WaveformTriangle, WaveformType: WaveformTriangle,
Harmonics: WaveformPresetHarmonics(WaveformTriangle, 3250.0, SampleRate), Harmonics: WaveformPresetHarmonics(WaveformTriangle, 5878.0, SampleRate),
Pan: 0.0, Pan: 0.0,
Group: "Infrastructure", Group: "Infrastructure",
Bursty: true,
}, },
classify.ClassSyslog: { classify.ClassSyslog: {
BaseHz: 3050.0, BaseHz: 6534.0, // A8 (approx) — auto-assign range
WaveformType: WaveformTriangle, WaveformType: WaveformTriangle,
Harmonics: WaveformPresetHarmonics(WaveformTriangle, 3050.0, SampleRate), Harmonics: WaveformPresetHarmonics(WaveformTriangle, 6534.0, SampleRate),
Pan: 0.2, Pan: 0.2,
Group: "Infrastructure", Group: "Infrastructure",
Bursty: true,
}, },
} }
+136
View File
@@ -0,0 +1,136 @@
package synth
// EnvelopeState tracks the current phase of an ADSR envelope.
type EnvelopeState int
const (
EnvIdle EnvelopeState = iota // silent, waiting for trigger
EnvAttack // ramping up to peak
EnvDecay // falling from peak to sustain level
EnvSustain // holding at sustain level (modulated by traffic rate)
EnvRelease // fading out after traffic stops
)
// Envelope is an ADSR envelope generator with exponential curves.
// It wraps the traffic-rate amplitude: the envelope shapes onset/offset,
// while the traffic rate modulates within the sustain phase.
type Envelope struct {
state EnvelopeState
level float64 // current envelope level [0, 1]
attackRate float64 // per-sample (exponential approach)
decayRate float64 // per-sample
sustainLevel float64 // target level during sustain [0, 1]
releaseRate float64 // per-sample
trafficAmp float64 // EMA-smoothed traffic amplitude [0, 1]
emaAlpha float64 // EMA coefficient for traffic smoothing
}
// EnvelopeParams configures ADSR timing.
type EnvelopeParams struct {
AttackSec float64 // seconds to reach peak
DecaySec float64 // seconds from peak to sustain level
SustainLevel float64 // sustain amplitude [0, 1]
ReleaseSec float64 // seconds to fade to silence
}
// Sustained flow envelope: slow, ambient feel.
var SustainedEnvParams = EnvelopeParams{
AttackSec: 2.0,
DecaySec: 1.0,
SustainLevel: 0.85,
ReleaseSec: 4.0,
}
// Bursty protocol envelope: percussive, event-like.
var BurstyEnvParams = EnvelopeParams{
AttackSec: 0.03,
DecaySec: 0.3,
SustainLevel: 0.0,
ReleaseSec: 1.5,
}
// NewEnvelope creates an ADSR envelope for the given params and sample rate.
// tau is the EMA time constant for traffic amplitude smoothing (seconds).
func NewEnvelope(params EnvelopeParams, sampleRate int, tau float64) *Envelope {
sr := float64(sampleRate)
return &Envelope{
state: EnvIdle,
attackRate: 1.0 / (params.AttackSec * sr),
decayRate: 1.0 / (params.DecaySec * sr),
sustainLevel: params.SustainLevel,
releaseRate: 1.0 / (params.ReleaseSec * sr),
emaAlpha: EMAAlpha(tau, sampleRate),
}
}
// Trigger starts the envelope (called when traffic first appears for this class).
func (e *Envelope) Trigger() {
if e.state == EnvIdle || e.state == EnvRelease {
e.state = EnvAttack
}
}
// Release begins the release phase (called when traffic stops).
func (e *Envelope) Release() {
if e.state != EnvIdle {
e.state = EnvRelease
}
}
// SetTrafficRate updates the EMA-smoothed traffic amplitude target.
// rate should be normalized [0, 1] (count / maxCount).
func (e *Envelope) SetTrafficRate(rate float64) {
e.trafficAmp += e.emaAlpha * (rate - e.trafficAmp)
}
// Advance processes one sample and returns the envelope amplitude [0, 1].
func (e *Envelope) Advance() float64 {
switch e.state {
case EnvIdle:
return 0
case EnvAttack:
e.level += e.attackRate * (1.05 - e.level) // overshoot target slightly for exponential feel
if e.level >= 1.0 {
e.level = 1.0
e.state = EnvDecay
}
case EnvDecay:
target := e.sustainLevel
e.level += e.decayRate * (target - e.level)
if e.level-target < 0.001 {
e.level = target
if target > 0 {
e.state = EnvSustain
} else {
// Bursty: sustain=0, go to release
e.state = EnvRelease
}
}
case EnvSustain:
// Modulate sustain level by traffic rate
target := e.sustainLevel * (WhisperFloor + (1.0-WhisperFloor)*e.trafficAmp)
e.level += e.emaAlpha * (target - e.level)
case EnvRelease:
e.level -= e.releaseRate * e.level
if e.level < 0.001 {
e.level = 0
e.state = EnvIdle
}
}
return e.level
}
// State returns the current envelope state (for testing).
func (e *Envelope) State() EnvelopeState {
return e.state
}
// Level returns the current envelope level (for testing).
func (e *Envelope) Level() float64 {
return e.level
}
+103
View File
@@ -0,0 +1,103 @@
package synth_test
import (
"testing"
"github.com/netsynth/netsynth/synth"
)
func TestEnvelopeStartsIdle(t *testing.T) {
env := synth.NewEnvelope(synth.SustainedEnvParams, synth.SampleRate, 1.0)
if env.State() != synth.EnvIdle {
t.Errorf("expected EnvIdle, got %v", env.State())
}
if env.Level() != 0 {
t.Errorf("expected level=0 in idle, got %v", env.Level())
}
}
func TestEnvelopeTriggerStartsAttack(t *testing.T) {
env := synth.NewEnvelope(synth.SustainedEnvParams, synth.SampleRate, 1.0)
env.Trigger()
if env.State() != synth.EnvAttack {
t.Errorf("expected EnvAttack after Trigger, got %v", env.State())
}
}
func TestEnvelopeAttackReachesPeak(t *testing.T) {
env := synth.NewEnvelope(synth.SustainedEnvParams, synth.SampleRate, 1.0)
env.Trigger()
// Advance through the full attack phase (2 seconds)
for i := 0; i < synth.SampleRate*3; i++ {
env.Advance()
}
if env.Level() < 0.8 {
t.Errorf("expected level >= 0.8 after attack, got %v", env.Level())
}
}
func TestEnvelopeReleaseDecays(t *testing.T) {
env := synth.NewEnvelope(synth.SustainedEnvParams, synth.SampleRate, 1.0)
env.Trigger()
// Build up
for i := 0; i < synth.SampleRate*3; i++ {
env.Advance()
}
peakLevel := env.Level()
env.Release()
// Advance through release (4 seconds)
for i := 0; i < synth.SampleRate*6; i++ {
env.Advance()
}
if env.Level() >= peakLevel*0.3 {
t.Errorf("expected level to decay well below peak after release, peak=%v, current=%v", peakLevel, env.Level())
}
}
func TestBurstyEnvelopeNoSustain(t *testing.T) {
env := synth.NewEnvelope(synth.BurstyEnvParams, synth.SampleRate, 1.0)
env.Trigger()
// Advance 2 seconds — bursty should have attacked, decayed (sustain=0), and be releasing
for i := 0; i < synth.SampleRate*2; i++ {
env.Advance()
}
// Should be very quiet (in release or idle)
if env.Level() > 0.1 {
t.Errorf("bursty envelope should be near zero after 2s, got %v (state=%v)", env.Level(), env.State())
}
}
func TestEnvelopeIdleOutputsZero(t *testing.T) {
env := synth.NewEnvelope(synth.SustainedEnvParams, synth.SampleRate, 1.0)
for i := 0; i < 100; i++ {
val := env.Advance()
if val != 0 {
t.Errorf("idle envelope should output 0, got %v at sample %d", val, i)
break
}
}
}
func TestEnvelopeRetriggerFromRelease(t *testing.T) {
env := synth.NewEnvelope(synth.SustainedEnvParams, synth.SampleRate, 1.0)
env.Trigger()
for i := 0; i < synth.SampleRate*3; i++ {
env.Advance()
}
env.Release()
for i := 0; i < synth.SampleRate; i++ {
env.Advance()
}
levelBeforeRetrigger := env.Level()
// Re-trigger
env.Trigger()
for i := 0; i < synth.SampleRate*3; i++ {
env.Advance()
}
if env.Level() <= levelBeforeRetrigger {
t.Errorf("re-triggered envelope should rise above release level, before=%v, after=%v",
levelBeforeRetrigger, env.Level())
}
}
+55 -24
View File
@@ -8,64 +8,95 @@ func EMAAlpha(tau float64, sampleRate int) float64 {
return 1.0 - math.Exp(-1.0/(tau*float64(sampleRate))) return 1.0 - math.Exp(-1.0/(tau*float64(sampleRate)))
} }
// Layer combines an oscillator with EMA amplitude smoothing for one traffic class. // Layer combines an oscillator with ADSR envelope, LFO modulation,
// and EMA amplitude smoothing for one traffic class.
type Layer struct { type Layer struct {
Config FreqConfig Config FreqConfig
Osc *Oscillator Osc *Oscillator
currentAmp float64 env *Envelope
targetAmp float64 pitchLFO *LFO
alpha float64 // EMA coefficient tremoloLFO *LFO
seen bool // whether this class has ever had count > 0 seen bool // whether this class has ever had count > 0
whisper float64 // whisper floor amplitude (D-08) 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). // NewLayer creates a Layer for the given config using the specified sample rate and EMA time constant (tau in seconds).
// If cfg.WaveformType is not WaveformCustom, harmonics are resolved from the preset at construction time. // If cfg.WaveformType is not WaveformCustom, harmonics are resolved from the preset at construction time.
// LFO and ADSR parameters are derived from the protocol group and bursty flag.
func NewLayer(cfg FreqConfig, sampleRate int, tau float64) *Layer { func NewLayer(cfg FreqConfig, sampleRate int, tau float64) *Layer {
if cfg.WaveformType != WaveformCustom { if cfg.WaveformType != WaveformCustom {
cfg.Harmonics = WaveformPresetHarmonics(cfg.WaveformType, cfg.BaseHz, sampleRate) cfg.Harmonics = WaveformPresetHarmonics(cfg.WaveformType, cfg.BaseHz, sampleRate)
} }
// Select envelope params based on bursty flag
envParams := SustainedEnvParams
if cfg.Bursty {
envParams = BurstyEnvParams
}
// Get LFO config for this protocol group
lfoCfg := LFOConfigForGroup(cfg.Group)
return &Layer{ return &Layer{
Config: cfg, Config: cfg,
Osc: NewOscillator(cfg.BaseHz, sampleRate), Osc: NewOscillator(cfg.BaseHz, sampleRate),
alpha: EMAAlpha(tau, sampleRate), env: NewEnvelope(envParams, sampleRate, tau),
whisper: WhisperFloor, pitchLFO: NewLFO(lfoCfg.PitchRate, lfoCfg.PitchDepth, sampleRate),
tremoloLFO: NewLFO(lfoCfg.TremoloRate, lfoCfg.TremoloDepth, sampleRate),
whisper: WhisperFloor,
} }
} }
// UpdateTarget sets the target amplitude from a packet count and max count across all classes. // UpdateTarget sets the envelope state and traffic rate from packet counts.
// Per D-07/D-08/D-09: once seen, floor is whisper; amplitude scales linearly with normalized rate. // Triggers the envelope on first appearance, releases when count drops to 0.
func (l *Layer) UpdateTarget(count int64, maxCount int64) { func (l *Layer) UpdateTarget(count int64, maxCount int64) {
if count > 0 { if count > 0 {
l.seen = true if !l.seen {
} l.seen = true
if !l.seen { l.env.Trigger()
l.targetAmp = 0.0 }
return // Re-trigger if we were in release/idle
if l.env.State() == EnvRelease || l.env.State() == EnvIdle {
l.env.Trigger()
}
} else if l.seen && count == 0 {
l.env.Release()
} }
// Update traffic rate for sustain modulation
normalizedRate := 0.0 normalizedRate := 0.0
if maxCount > 0 { if maxCount > 0 {
normalizedRate = float64(count) / float64(maxCount) normalizedRate = float64(count) / float64(maxCount)
} }
l.targetAmp = l.whisper + (1.0-l.whisper)*normalizedRate l.env.SetTrafficRate(normalizedRate)
} }
// AdvanceSample renders one sample and advances the EMA amplitude toward target. // AdvanceSample renders one sample with LFO modulation and ADSR envelope.
// Returns the raw mono sample (before pan/gain).
func (l *Layer) AdvanceSample() float64 { func (l *Layer) AdvanceSample() float64 {
// Pitch modulation: LFO shifts frequency by a few cents
pitchMod := l.pitchLFO.Advance()
l.Osc.freq = PitchLFOFreq(l.Config.BaseHz, pitchMod)
// Generate waveform sample
sample := l.Osc.Advance(l.Config.Harmonics) sample := l.Osc.Advance(l.Config.Harmonics)
l.currentAmp += l.alpha * (l.targetAmp - l.currentAmp)
return sample * l.currentAmp // Apply ADSR envelope
envAmp := l.env.Advance()
// Tremolo modulation: LFO modulates amplitude
tremoloMod := 1.0 + l.tremoloLFO.Advance() // [1-depth, 1+depth]
return sample * envAmp * tremoloMod
} }
// CurrentAmp returns the current amplitude (for testing). // CurrentAmp returns the current envelope level (for testing).
func (l *Layer) CurrentAmp() float64 { func (l *Layer) CurrentAmp() float64 {
return l.currentAmp return l.env.Level()
} }
// TargetAmp returns the target amplitude (for testing). // TargetAmp returns the current traffic amplitude in the envelope (for testing).
func (l *Layer) TargetAmp() float64 { func (l *Layer) TargetAmp() float64 {
return l.targetAmp return l.env.trafficAmp
} }
// Seen returns whether this layer has ever received traffic (for testing). // Seen returns whether this layer has ever received traffic (for testing).
+75 -40
View File
@@ -6,83 +6,118 @@ import (
"github.com/netsynth/netsynth/synth" "github.com/netsynth/netsynth/synth"
) )
func TestEMAAmplitudeRise(t *testing.T) { func TestEnvelopeAttackRise(t *testing.T) {
cfg2 := synth.FreqConfig{ // Sustained protocol (Bursty=false): slow 2s attack
cfg := synth.FreqConfig{
BaseHz: 440.0, BaseHz: 440.0,
Harmonics: []synth.HarmonicDef{{Ratio: 1, Amplitude: 1.0}}, Harmonics: []synth.HarmonicDef{{Ratio: 1, Amplitude: 1.0}},
Pan: 0.0, Pan: 0.0,
Group: "Web",
} }
layer := synth.NewLayer(cfg2, synth.SampleRate, 1.0) layer := synth.NewLayer(cfg, synth.SampleRate, 1.0)
// Force the layer to have been seen and set target to 1.0 // Trigger envelope by sending traffic
layer.UpdateTarget(100, 100) layer.UpdateTarget(100, 100)
// After 1 second (SampleRate samples) with tau=1.0, currentAmp should be > 0.5 // After 2 seconds (full attack), envelope level should be significant
// EMA: after tau seconds, amplitude reaches ~63% of target for i := 0; i < synth.SampleRate*2; i++ {
for i := 0; i < synth.SampleRate; i++ {
layer.AdvanceSample() layer.AdvanceSample()
} }
if layer.CurrentAmp() <= 0.5 { if layer.CurrentAmp() <= 0.5 {
t.Errorf("expected currentAmp > 0.5 after 1 second rise, got %.4f", layer.CurrentAmp()) t.Errorf("expected envelope level > 0.5 after 2 second attack, got %.4f", layer.CurrentAmp())
} }
} }
func TestEMAAmplitudeDecay(t *testing.T) { func TestBurstyEnvelopeFastAttack(t *testing.T) {
// Bursty protocol: fast 30ms attack
cfg := synth.FreqConfig{ cfg := synth.FreqConfig{
BaseHz: 440.0, BaseHz: 440.0,
Harmonics: []synth.HarmonicDef{{Ratio: 1, Amplitude: 1.0}}, Harmonics: []synth.HarmonicDef{{Ratio: 1, Amplitude: 1.0}},
Pan: 0.0, Pan: 0.0,
Group: "Infrastructure",
Bursty: true,
} }
layer := synth.NewLayer(cfg, synth.SampleRate, 1.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 layer.UpdateTarget(100, 100)
// 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 // After 100ms, bursty envelope should have peaked and be decaying
// Force currentAmp to 1.0 by running a few cycles at target 1.0 for i := 0; i < synth.SampleRate/10; i++ {
layer.AdvanceSample()
}
// Bursty: sustain=0, so it should already be fading
// But it should have been triggered (not zero at peak)
if !layer.Seen() {
t.Error("expected layer.Seen() to be true after receiving traffic")
}
}
func TestEnvelopeRelease(t *testing.T) {
cfg := synth.FreqConfig{
BaseHz: 440.0,
Harmonics: []synth.HarmonicDef{{Ratio: 1, Amplitude: 1.0}},
Pan: 0.0,
Group: "Web",
}
layer := synth.NewLayer(cfg, synth.SampleRate, 1.0)
// Build up
layer.UpdateTarget(100, 100)
for i := 0; i < synth.SampleRate*3; i++ { for i := 0; i < synth.SampleRate*3; i++ {
layer.AdvanceSample() layer.AdvanceSample()
} }
// Now decay: set count=0 (whisper floor kicks in) peakAmp := layer.CurrentAmp()
// Release: set count to 0
layer.UpdateTarget(0, 100) layer.UpdateTarget(0, 100)
// After 1 second, currentAmp should be < 0.5 for i := 0; i < synth.SampleRate*5; i++ {
for i := 0; i < synth.SampleRate; i++ {
layer.AdvanceSample() layer.AdvanceSample()
} }
if layer.CurrentAmp() >= 0.5 {
t.Errorf("expected currentAmp < 0.5 after 1 second decay, got %.4f", layer.CurrentAmp()) // After 5 seconds of release (release time is 4s), should be much lower
if layer.CurrentAmp() >= peakAmp*0.5 {
t.Errorf("expected envelope to decay significantly, peak=%.4f, current=%.4f", peakAmp, layer.CurrentAmp())
} }
} }
func TestWhisperFloor(t *testing.T) { func TestLayerNotSeenIssilent(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{ cfg := synth.FreqConfig{
BaseHz: 440.0, BaseHz: 440.0,
Harmonics: []synth.HarmonicDef{{Ratio: 1, Amplitude: 1.0}}, Harmonics: []synth.HarmonicDef{{Ratio: 1, Amplitude: 1.0}},
Pan: 0.0, Pan: 0.0,
Group: "Web",
} }
layer := synth.NewLayer(cfg, synth.SampleRate, 1.0) layer := synth.NewLayer(cfg, synth.SampleRate, 1.0)
// Never seen — UpdateTarget with zero count // Never seen — UpdateTarget with zero count
layer.UpdateTarget(0, 100) layer.UpdateTarget(0, 100)
if layer.TargetAmp() != 0.0 { if layer.Seen() {
t.Errorf("expected targetAmp == 0.0 for unseen layer, got %.4f", layer.TargetAmp()) t.Error("expected Seen()=false for layer that never had traffic")
}
// Advance some samples — output should be zero
for i := 0; i < 100; i++ {
sample := layer.AdvanceSample()
if sample != 0 {
t.Errorf("expected zero output for unseen layer, got %v at sample %d", sample, i)
break
}
}
}
func TestLayerSeenFlag(t *testing.T) {
cfg := synth.FreqConfig{
BaseHz: 440.0,
Harmonics: []synth.HarmonicDef{{Ratio: 1, Amplitude: 1.0}},
Pan: 0.0,
Group: "Web",
}
layer := synth.NewLayer(cfg, synth.SampleRate, 1.0)
if layer.Seen() {
t.Error("expected Seen()=false initially")
}
layer.UpdateTarget(1, 100)
if !layer.Seen() {
t.Error("expected Seen()=true after receiving traffic")
} }
} }
+72
View File
@@ -0,0 +1,72 @@
package synth
import "math"
// LFO is a low-frequency oscillator for modulating synthesis parameters.
// Uses sine waveform. Rate is in Hz (typically 0.01-0.5 Hz for ambient feel).
type LFO struct {
phase float64
rate float64 // Hz
depth float64 // modulation depth (interpretation depends on usage)
sr float64
}
// NewLFO creates an LFO at the given rate (Hz) and depth.
func NewLFO(rate, depth float64, sampleRate int) *LFO {
return &LFO{
rate: rate,
depth: depth,
sr: float64(sampleRate),
}
}
// Advance returns the current LFO value in [-depth, +depth] and advances the phase.
func (l *LFO) Advance() float64 {
val := math.Sin(2 * math.Pi * l.phase)
l.phase += l.rate / l.sr
if l.phase >= 1.0 {
l.phase -= math.Floor(l.phase)
}
return val * l.depth
}
// PitchLFOFreq returns a pitch-modulated frequency given a base frequency
// and an LFO value in semitones. For example, lfoVal=0.1 shifts pitch up by 0.1 semitones.
func PitchLFOFreq(baseHz, lfoSemitones float64) float64 {
return baseHz * math.Pow(2.0, lfoSemitones/12.0)
}
// LFOConfig holds LFO parameters for a synthesis layer.
// Each protocol gets unique, incommensurable rates so modulation patterns never repeat.
type LFOConfig struct {
PitchRate float64 // Hz, typically 0.02-0.08
PitchDepth float64 // semitones, typically 0.05-0.15
TremoloRate float64 // Hz, typically 0.05-0.3
TremoloDepth float64 // amplitude fraction, typically 0.05-0.2
}
// layerLFOConfigs provides unique incommensurable LFO rates per protocol group.
// Rates chosen as non-integer ratios to avoid periodic sync (Eno technique).
var groupLFOConfigs = map[string]LFOConfig{
"Infrastructure": {PitchRate: 0.031, PitchDepth: 0.08, TremoloRate: 0.053, TremoloDepth: 0.12},
"Web": {PitchRate: 0.043, PitchDepth: 0.10, TremoloRate: 0.071, TremoloDepth: 0.15},
"Mail": {PitchRate: 0.037, PitchDepth: 0.07, TremoloRate: 0.059, TremoloDepth: 0.10},
"Remote Access": {PitchRate: 0.029, PitchDepth: 0.12, TremoloRate: 0.047, TremoloDepth: 0.18},
"File Transfer": {PitchRate: 0.041, PitchDepth: 0.09, TremoloRate: 0.067, TremoloDepth: 0.13},
"Database": {PitchRate: 0.023, PitchDepth: 0.06, TremoloRate: 0.083, TremoloDepth: 0.10},
"VoIP": {PitchRate: 0.019, PitchDepth: 0.05, TremoloRate: 0.091, TremoloDepth: 0.08},
"Unknown": {PitchRate: 0.053, PitchDepth: 0.15, TremoloRate: 0.037, TremoloDepth: 0.20},
}
// DefaultLFOConfig is the fallback for groups not in the map.
var DefaultLFOConfig = LFOConfig{
PitchRate: 0.033, PitchDepth: 0.10, TremoloRate: 0.057, TremoloDepth: 0.15,
}
// LFOConfigForGroup returns the LFO config for a protocol group.
func LFOConfigForGroup(group string) LFOConfig {
if cfg, ok := groupLFOConfigs[group]; ok {
return cfg
}
return DefaultLFOConfig
}
+90
View File
@@ -0,0 +1,90 @@
package synth_test
import (
"math"
"testing"
"github.com/netsynth/netsynth/synth"
)
func TestLFOBounds(t *testing.T) {
lfo := synth.NewLFO(1.0, 0.5, 44100) // 1 Hz, depth 0.5
for i := 0; i < 44100; i++ {
val := lfo.Advance()
if val < -0.5 || val > 0.5 {
t.Errorf("LFO value %v out of bounds [-0.5, 0.5] at sample %d", val, i)
break
}
}
}
func TestLFOPeriod(t *testing.T) {
// A 1 Hz LFO should complete one cycle in exactly SampleRate samples
lfo := synth.NewLFO(1.0, 1.0, 44100)
// Advance to first zero-crossing (quarter period)
var firstPositive float64
for i := 0; i < 44100; i++ {
val := lfo.Advance()
if i == 0 {
firstPositive = val
}
// After one full cycle, value should be close to the first value
if i == 44099 {
lastVal := val
// They won't be exactly equal due to phase advancement, but should be close
if math.Abs(lastVal-firstPositive) > 0.01 {
t.Errorf("LFO not periodic: first=%v, after 1 cycle=%v", firstPositive, lastVal)
}
}
}
}
func TestPitchLFOFreq(t *testing.T) {
// 0 semitones should return baseHz unchanged
if synth.PitchLFOFreq(440.0, 0.0) != 440.0 {
t.Errorf("PitchLFOFreq(440, 0) should be 440, got %v", synth.PitchLFOFreq(440.0, 0.0))
}
// 12 semitones = one octave up
result := synth.PitchLFOFreq(440.0, 12.0)
if math.Abs(result-880.0) > 0.01 {
t.Errorf("PitchLFOFreq(440, 12) should be 880, got %v", result)
}
// Small detuning: 0.1 semitones
result = synth.PitchLFOFreq(440.0, 0.1)
if result <= 440.0 || result >= 445.0 {
t.Errorf("PitchLFOFreq(440, 0.1) should be slightly above 440, got %v", result)
}
}
func TestLFOConfigForGroup(t *testing.T) {
cfg := synth.LFOConfigForGroup("Web")
if cfg.PitchRate == 0 {
t.Error("Web group should have non-zero PitchRate")
}
if cfg.TremoloRate == 0 {
t.Error("Web group should have non-zero TremoloRate")
}
// Unknown group should return default
cfg2 := synth.LFOConfigForGroup("NonexistentGroup")
if cfg2.PitchRate == 0 {
t.Error("fallback config should have non-zero PitchRate")
}
}
func TestGroupLFORatesIncommensurable(t *testing.T) {
// Verify that no two groups share the exact same pitch or tremolo rate
groups := []string{"Infrastructure", "Web", "Mail", "Remote Access", "File Transfer", "Database", "VoIP", "Unknown"}
pitchRates := make(map[float64]string)
tremoloRates := make(map[float64]string)
for _, g := range groups {
cfg := synth.LFOConfigForGroup(g)
if prev, exists := pitchRates[cfg.PitchRate]; exists {
t.Errorf("groups %q and %q share PitchRate=%v", prev, g, cfg.PitchRate)
}
pitchRates[cfg.PitchRate] = g
if prev, exists := tremoloRates[cfg.TremoloRate]; exists {
t.Errorf("groups %q and %q share TremoloRate=%v", prev, g, cfg.TremoloRate)
}
tremoloRates[cfg.TremoloRate] = g
}
}