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:
+75
-40
@@ -6,83 +6,118 @@ import (
|
||||
"github.com/netsynth/netsynth/synth"
|
||||
)
|
||||
|
||||
func TestEMAAmplitudeRise(t *testing.T) {
|
||||
cfg2 := synth.FreqConfig{
|
||||
func TestEnvelopeAttackRise(t *testing.T) {
|
||||
// Sustained protocol (Bursty=false): slow 2s attack
|
||||
cfg := synth.FreqConfig{
|
||||
BaseHz: 440.0,
|
||||
Harmonics: []synth.HarmonicDef{{Ratio: 1, Amplitude: 1.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)
|
||||
|
||||
// After 1 second (SampleRate samples) with tau=1.0, currentAmp should be > 0.5
|
||||
// EMA: after tau seconds, amplitude reaches ~63% of target
|
||||
for i := 0; i < synth.SampleRate; i++ {
|
||||
// After 2 seconds (full attack), envelope level should be significant
|
||||
for i := 0; i < synth.SampleRate*2; i++ {
|
||||
layer.AdvanceSample()
|
||||
}
|
||||
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{
|
||||
BaseHz: 440.0,
|
||||
Harmonics: []synth.HarmonicDef{{Ratio: 1, Amplitude: 1.0}},
|
||||
Pan: 0.0,
|
||||
Group: "Infrastructure",
|
||||
Bursty: true,
|
||||
}
|
||||
layer := synth.NewLayer(cfg, synth.SampleRate, 1.0)
|
||||
|
||||
// Set up: layer has been seen (currentAmp starts at 1.0) and target is whisper floor
|
||||
// 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
|
||||
// Force currentAmp to 1.0 by running a few cycles at target 1.0
|
||||
layer.UpdateTarget(100, 100)
|
||||
|
||||
// After 100ms, bursty envelope should have peaked and be decaying
|
||||
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++ {
|
||||
layer.AdvanceSample()
|
||||
}
|
||||
// Now decay: set count=0 (whisper floor kicks in)
|
||||
peakAmp := layer.CurrentAmp()
|
||||
|
||||
// Release: set count to 0
|
||||
layer.UpdateTarget(0, 100)
|
||||
// After 1 second, currentAmp should be < 0.5
|
||||
for i := 0; i < synth.SampleRate; i++ {
|
||||
for i := 0; i < synth.SampleRate*5; i++ {
|
||||
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) {
|
||||
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) {
|
||||
func TestLayerNotSeenIssilent(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)
|
||||
|
||||
// Never seen — UpdateTarget with zero count
|
||||
layer.UpdateTarget(0, 100)
|
||||
if layer.TargetAmp() != 0.0 {
|
||||
t.Errorf("expected targetAmp == 0.0 for unseen layer, got %.4f", layer.TargetAmp())
|
||||
if layer.Seen() {
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user