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>
This commit is contained in:
2026-03-27 19:38:58 +01:00
co-authored by Claude Opus 4.6
parent 494385b528
commit 6b2db48339
14 changed files with 824 additions and 255 deletions
+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)
counts := make(map[classify.TrafficClass]int64)
counts[classify.ClassICMP] = 100
counts[classify.ClassHTTPS] = 100 // sustained protocol — slow attack
snap := classify.WindowSnapshot{
Counts: counts,
TotalPackets: 100,
WindowIndex: 0,
}
// Compute RMS for first and last window render
// Compute RMS for first window render
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
for i := 0; i < 4; i++ {
for i := 0; i < 8; i++ {
rmsLast = windowRMS(b.RenderWindow(snap))
}
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 {
t.Errorf("NewBank with 3 configs has %d layers, want 3", len(b.layers))
}
// Verify gainPerLayer is 1/3
expected := 1.0 / 3.0
if b.gainPerLayer != expected {
// Verify gainPerLayer accounts for tremolo headroom: 1/(3 * 1.2)
expected := 1.0 / (3.0 * (1.0 + maxTremoloDepth))
if math.Abs(b.gainPerLayer-expected) > 1e-12 {
t.Errorf("gainPerLayer = %v, want %v", b.gainPerLayer, expected)
}
}