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
+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())
}
}