From 9e7f3e4850daff87b94f9dfc4cbc2af1f2ca4cbc Mon Sep 17 00:00:00 2001 From: Ilja Rotar Date: Sun, 7 Dec 2025 17:41:23 +0100 Subject: [PATCH 01/23] fader wip: not working yet --- README.md | 2 ++ examples/sine-440.yaml | 1 + module/envelope.go | 21 +++++++++++++++------ module/fader.go | 30 ++++++++++++++++++++++++++++++ module/fader_test.go | 35 +++++++++++++++++++++++++++++++++++ module/module.go | 28 ++++++++++++++++------------ module/oscillator.go | 27 +++++++++++++++++++++------ synth/synth.go | 6 ++---- 8 files changed, 122 insertions(+), 28 deletions(-) create mode 100644 module/fader.go create mode 100644 module/fader_test.go diff --git a/README.md b/README.md index cad3299..d346f54 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ + + # Synth A modular synthesizer for the command line written in [golang](https://go.dev/). diff --git a/examples/sine-440.yaml b/examples/sine-440.yaml index 7188104..536b9c4 100644 --- a/examples/sine-440.yaml +++ b/examples/sine-440.yaml @@ -3,5 +3,6 @@ out: sine oscillators: sine: + fade: 1 type: Sine freq: 440 diff --git a/module/envelope.go b/module/envelope.go index f09c8ae..ca50662 100644 --- a/module/envelope.go +++ b/module/envelope.go @@ -7,12 +7,20 @@ import ( type ( Envelope struct { Module - Attack float64 `yaml:"attack"` - Decay float64 `yaml:"decay"` - Release float64 `yaml:"release"` - Peak float64 `yaml:"peak"` - Level float64 `yaml:"level"` - Gate string `yaml:"gate"` + Attack float64 `yaml:"attack"` + Decay float64 `yaml:"decay"` + Release float64 `yaml:"release"` + Peak float64 `yaml:"peak"` + Level float64 `yaml:"level"` + Gate string `yaml:"gate"` + Fade float64 `yaml:"fade"` + + targetAttack float64 + targetDecay float64 + targetRelease float64 + targetPeak float64 + targetLevel float64 + triggeredAt float64 releasedAt float64 gateValue float64 @@ -37,6 +45,7 @@ func (e *Envelope) initialize() { e.Release = calc.Limit(e.Release, envelopeRange) e.Peak = calc.Limit(e.Peak, gainRange) e.Level = calc.Limit(e.Level, gainRange) + e.Fade = calc.Limit(e.Fade, fadeRange) } func (e *Envelope) Update(new *Envelope) { diff --git a/module/fader.go b/module/fader.go new file mode 100644 index 0000000..da86357 --- /dev/null +++ b/module/fader.go @@ -0,0 +1,30 @@ +package module + +type ( + fader struct { + current float64 + target float64 + step float64 + } +) + +func (f *fader) initialize(duration, sampleRate float64) { + delta := f.target - f.current + if duration == 0 || sampleRate == 0 { + f.step = delta + } + f.step = delta / (duration * sampleRate) +} + +func (f *fader) fade() float64 { + if f.current == f.target { + return f.current + } + + new := f.current + f.step + if (f.target-f.current >= 0) != (f.target-new >= 0) { + f.current = f.target + } + f.current = new + return f.current +} diff --git a/module/fader_test.go b/module/fader_test.go new file mode 100644 index 0000000..3545b10 --- /dev/null +++ b/module/fader_test.go @@ -0,0 +1,35 @@ +package module + +import ( + "testing" +) + +func Test_fader_initialize(t *testing.T) { + tests := []struct { + name string + duration float64 + sampleRate float64 + f *fader + }{ + // TODO: Add test cases. + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.f.initialize(tt.duration, tt.sampleRate) + }) + } +} + +func Test_fader_fade(t *testing.T) { + tests := []struct { + name string + f *fader + }{ + // TODO: Add test cases. + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.f.fade() + }) + } +} diff --git a/module/module.go b/module/module.go index ce0699f..f682864 100644 --- a/module/module.go +++ b/module/module.go @@ -19,6 +19,22 @@ type ( ) var ( + bpmRange = calc.Range{ + Min: 0, + Max: 2000, + } + envelopeRange = calc.Range{ + Min: 1e-15, + Max: 3600, + } + fadeRange = calc.Range{ + Min: 0, + Max: 3600, + } + freqRange = calc.Range{ + Min: 0, + Max: 20000, + } gainRange = calc.Range{ Min: 0, Max: 1, @@ -27,22 +43,10 @@ var ( Min: -1, Max: 1, } - freqRange = calc.Range{ - Min: 0, - Max: 20000, - } panRange = calc.Range{ Min: -1, Max: 1, } - bpmRange = calc.Range{ - Min: 0, - Max: 2000, - } - envelopeRange = calc.Range{ - Min: 1e-15, - Max: 3600, - } pitchRange = calc.Range{ Min: 400, Max: 500, diff --git a/module/oscillator.go b/module/oscillator.go index 1bf70df..3213319 100644 --- a/module/oscillator.go +++ b/module/oscillator.go @@ -10,11 +10,15 @@ import ( type ( Oscillator struct { Module - Type oscillatorType `yaml:"type"` - Freq float64 `yaml:"freq"` - CV string `yaml:"cv"` - Mod string `yaml:"mod"` - Phase float64 `yaml:"phase"` + Type oscillatorType `yaml:"type"` + Freq float64 `yaml:"freq"` + CV string `yaml:"cv"` + Mod string `yaml:"mod"` + Phase float64 `yaml:"phase"` + Fade float64 `yaml:"fade"` + + freqFader *fader + // TODO: add a phaseFader? signal SignalFunc sampleRate float64 arg float64 @@ -47,6 +51,13 @@ func (m OscillatorMap) Initialize(sampleRate float64) error { func (o *Oscillator) initialize(sampleRate float64) error { o.sampleRate = sampleRate o.Freq = calc.Limit(o.Freq, freqRange) + o.Fade = calc.Limit(o.Fade, fadeRange) + + o.freqFader = &fader{ + current: o.Freq, + target: o.Freq, + } + o.freqFader.initialize(o.Fade, sampleRate) signal, err := newSignalFunc(o.Type) if err != nil { @@ -63,11 +74,14 @@ func (o *Oscillator) Update(new *Oscillator) { } o.Type = new.Type - o.Freq = new.Freq o.CV = new.CV o.Mod = new.Mod o.Phase = new.Phase + o.Fade = new.Fade o.signal = new.signal + + o.freqFader.target = new.Freq + o.freqFader.initialize(o.Fade, o.sampleRate) } func (o *Oscillator) Step(modules ModuleMap) { @@ -88,4 +102,5 @@ func (o *Oscillator) Step(modules ModuleMap) { } o.arg += twoPi * freq * mod / o.sampleRate + o.Freq = o.freqFader.fade() } diff --git a/synth/synth.go b/synth/synth.go index fcb1171..37d106f 100644 --- a/synth/synth.go +++ b/synth/synth.go @@ -221,12 +221,10 @@ func (s *Synth) step() { } func secondsToStep(seconds, delta, sampleRate float64) float64 { - if seconds == 0 { + if sampleRate == 0 || seconds == 0 { return delta } - steps := seconds * sampleRate - step := delta / steps - return step + return delta / (seconds * sampleRate) } func (s *Synth) makeModulesMap() { From e47ede809f9f1e50e1747610f5487a423b7ad3f5 Mon Sep 17 00:00:00 2001 From: Ilja Rotar Date: Wed, 10 Dec 2025 17:30:28 +0100 Subject: [PATCH 02/23] fader works --- module/fader.go | 8 ++-- module/fader_test.go | 104 +++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 106 insertions(+), 6 deletions(-) diff --git a/module/fader.go b/module/fader.go index da86357..53cdb37 100644 --- a/module/fader.go +++ b/module/fader.go @@ -12,19 +12,21 @@ func (f *fader) initialize(duration, sampleRate float64) { delta := f.target - f.current if duration == 0 || sampleRate == 0 { f.step = delta + return } f.step = delta / (duration * sampleRate) } func (f *fader) fade() float64 { + // technically, this case is covered below, but for efficiency we make an early return here if f.current == f.target { return f.current } - new := f.current + f.step - if (f.target-f.current >= 0) != (f.target-new >= 0) { + f.current += f.step + if (f.current > f.target) == (f.step > 0) { f.current = f.target } - f.current = new + return f.current } diff --git a/module/fader_test.go b/module/fader_test.go index 3545b10..10ad599 100644 --- a/module/fader_test.go +++ b/module/fader_test.go @@ -10,12 +10,45 @@ func Test_fader_initialize(t *testing.T) { duration float64 sampleRate float64 f *fader + wantStep float64 }{ - // TODO: Add test cases. + { + name: "duration is zero", + duration: 0, + sampleRate: 44100, + f: &fader{ + current: 440, + target: 330, + }, + wantStep: -110, + }, + { + name: "sample rate is zero", + duration: 1, + sampleRate: 0, + f: &fader{ + current: 440, + target: 330, + }, + wantStep: -110, + }, + { + name: "both non-zero", + duration: 5, + sampleRate: 2000, + f: &fader{ + current: 400, + target: 300, + }, + wantStep: -0.01, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { tt.f.initialize(tt.duration, tt.sampleRate) + if tt.f.step != tt.wantStep { + t.Errorf("fader.initialize() step = %v, want %v", tt.f.step, tt.wantStep) + } }) } } @@ -24,12 +57,77 @@ func Test_fader_fade(t *testing.T) { tests := []struct { name string f *fader + want float64 }{ - // TODO: Add test cases. + { + name: "target equals current", + f: &fader{ + current: 400, + target: 400, + step: 10, + }, + want: 400, + }, + { + name: "current lower than target", + f: &fader{ + current: 400, + target: 500, + step: 10, + }, + want: 410, + }, + { + name: "current higher than target", + f: &fader{ + current: 400, + target: 300, + step: -10, + }, + want: 390, + }, + { + name: "current lower than target but exceeds it after update", + f: &fader{ + current: 395, + target: 400, + step: 10, + }, + want: 400, + }, + { + name: "current higher than target but lower after update", + f: &fader{ + current: 305, + target: 300, + step: -10, + }, + want: 300, + }, + { + name: "current higher than target but step is positive", + f: &fader{ + current: 305, + target: 300, + step: 10, + }, + want: 300, + }, + { + name: "current lower than target but step is negative", + f: &fader{ + current: 295, + target: 300, + step: -10, + }, + want: 300, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - tt.f.fade() + if got := tt.f.fade(); got != tt.want { + t.Errorf("fader.fade() = %v, want %v", got, tt.want) + } }) } } From 0a121045ab0e6dd666901dc9a2ba17a1a77ba6e7 Mon Sep 17 00:00:00 2001 From: Ilja Rotar Date: Wed, 10 Dec 2025 17:34:36 +0100 Subject: [PATCH 03/23] add phase fader to osc --- module/oscillator.go | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/module/oscillator.go b/module/oscillator.go index 3213319..5478658 100644 --- a/module/oscillator.go +++ b/module/oscillator.go @@ -17,8 +17,8 @@ type ( Phase float64 `yaml:"phase"` Fade float64 `yaml:"fade"` - freqFader *fader - // TODO: add a phaseFader? + freqFader *fader + phaseFader *fader signal SignalFunc sampleRate float64 arg float64 @@ -59,6 +59,12 @@ func (o *Oscillator) initialize(sampleRate float64) error { } o.freqFader.initialize(o.Fade, sampleRate) + o.phaseFader = &fader{ + current: o.Phase, + target: o.Phase, + } + o.phaseFader.initialize(o.Fade, sampleRate) + signal, err := newSignalFunc(o.Type) if err != nil { return err @@ -76,12 +82,13 @@ func (o *Oscillator) Update(new *Oscillator) { o.Type = new.Type o.CV = new.CV o.Mod = new.Mod - o.Phase = new.Phase o.Fade = new.Fade o.signal = new.signal o.freqFader.target = new.Freq o.freqFader.initialize(o.Fade, o.sampleRate) + o.phaseFader.target = new.Phase + o.phaseFader.initialize(o.Fade, o.sampleRate) } func (o *Oscillator) Step(modules ModuleMap) { @@ -103,4 +110,5 @@ func (o *Oscillator) Step(modules ModuleMap) { o.arg += twoPi * freq * mod / o.sampleRate o.Freq = o.freqFader.fade() + o.Phase = o.phaseFader.fade() } From 4b89ca80ecf0b9c079d48de17da9cf4a51f8b543 Mon Sep 17 00:00:00 2001 From: Ilja Rotar Date: Wed, 10 Dec 2025 17:53:49 +0100 Subject: [PATCH 04/23] env faders --- module/envelope.go | 69 +++++++++++++++++++++++++++++++++++--------- module/oscillator.go | 17 ++++++----- synth/synth.go | 2 +- 3 files changed, 66 insertions(+), 22 deletions(-) diff --git a/module/envelope.go b/module/envelope.go index ca50662..ab62a2e 100644 --- a/module/envelope.go +++ b/module/envelope.go @@ -15,37 +15,61 @@ type ( Gate string `yaml:"gate"` Fade float64 `yaml:"fade"` - targetAttack float64 - targetDecay float64 - targetRelease float64 - targetPeak float64 - targetLevel float64 - triggeredAt float64 releasedAt float64 gateValue float64 level float64 + sampleRate float64 + + attackFader *fader + decayFader *fader + releaseFader *fader + peakFader *fader + levelFader *fader } EnvelopeMap map[string]*Envelope ) -func (m EnvelopeMap) Initialize() { +func (m EnvelopeMap) Initialize(sampleRate float64) { for _, e := range m { if e == nil { continue } - e.initialize() + e.initialize(sampleRate) } } -func (e *Envelope) initialize() { +func (e *Envelope) initialize(sampleRate float64) { + e.sampleRate = sampleRate e.Attack = calc.Limit(e.Attack, envelopeRange) e.Decay = calc.Limit(e.Decay, envelopeRange) e.Release = calc.Limit(e.Release, envelopeRange) e.Peak = calc.Limit(e.Peak, gainRange) e.Level = calc.Limit(e.Level, gainRange) e.Fade = calc.Limit(e.Fade, fadeRange) + + e.attackFader = &fader{ + current: e.Attack, + target: e.Attack, + } + e.decayFader = &fader{ + current: e.Decay, + target: e.Decay, + } + e.releaseFader = &fader{ + current: e.Release, + target: e.Release, + } + e.peakFader = &fader{ + current: e.Peak, + target: e.Peak, + } + e.levelFader = &fader{ + current: e.Level, + target: e.Level, + } + e.initializeFaders() } func (e *Envelope) Update(new *Envelope) { @@ -53,12 +77,15 @@ func (e *Envelope) Update(new *Envelope) { return } - e.Attack = new.Attack - e.Decay = new.Decay - e.Release = new.Release - e.Peak = new.Peak - e.Level = new.Level e.Gate = new.Gate + e.Fade = new.Fade + + e.attackFader.target = new.Attack + e.decayFader.target = new.Decay + e.releaseFader.target = new.Release + e.peakFader.target = new.Peak + e.levelFader.target = new.Level + e.initializeFaders() } func (e *Envelope) Step(t float64, modules ModuleMap) { @@ -82,6 +109,20 @@ func (e *Envelope) Step(t float64, modules ModuleMap) { } e.gateValue = gateValue + + e.Attack = e.attackFader.fade() + e.Decay = e.decayFader.fade() + e.Release = e.releaseFader.fade() + e.Peak = e.peakFader.fade() + e.Level = e.levelFader.fade() +} + +func (e *Envelope) initializeFaders() { + e.attackFader.initialize(e.Fade, e.sampleRate) + e.decayFader.initialize(e.Fade, e.sampleRate) + e.releaseFader.initialize(e.Fade, e.sampleRate) + e.peakFader.initialize(e.Peak, e.sampleRate) + e.levelFader.initialize(e.Fade, e.sampleRate) } func (e *Envelope) getValue(t float64) float64 { diff --git a/module/oscillator.go b/module/oscillator.go index 5478658..9fe85a0 100644 --- a/module/oscillator.go +++ b/module/oscillator.go @@ -17,11 +17,12 @@ type ( Phase float64 `yaml:"phase"` Fade float64 `yaml:"fade"` - freqFader *fader - phaseFader *fader signal SignalFunc sampleRate float64 arg float64 + + freqFader *fader + phaseFader *fader } OscillatorMap map[string]*Oscillator @@ -57,13 +58,11 @@ func (o *Oscillator) initialize(sampleRate float64) error { current: o.Freq, target: o.Freq, } - o.freqFader.initialize(o.Fade, sampleRate) - o.phaseFader = &fader{ current: o.Phase, target: o.Phase, } - o.phaseFader.initialize(o.Fade, sampleRate) + o.initializeFaders() signal, err := newSignalFunc(o.Type) if err != nil { @@ -86,9 +85,8 @@ func (o *Oscillator) Update(new *Oscillator) { o.signal = new.signal o.freqFader.target = new.Freq - o.freqFader.initialize(o.Fade, o.sampleRate) o.phaseFader.target = new.Phase - o.phaseFader.initialize(o.Fade, o.sampleRate) + o.initializeFaders() } func (o *Oscillator) Step(modules ModuleMap) { @@ -112,3 +110,8 @@ func (o *Oscillator) Step(modules ModuleMap) { o.Freq = o.freqFader.fade() o.Phase = o.phaseFader.fade() } + +func (o *Oscillator) initializeFaders() { + o.freqFader.initialize(o.Fade, o.sampleRate) + o.phaseFader.initialize(o.Fade, o.sampleRate) +} diff --git a/synth/synth.go b/synth/synth.go index 37d106f..98ca6a4 100644 --- a/synth/synth.go +++ b/synth/synth.go @@ -76,7 +76,7 @@ func (s *Synth) Initialize(sampleRate float64) error { return err } - s.Envelopes.Initialize() + s.Envelopes.Initialize(sampleRate) s.Gates.Initialze(sampleRate) s.Pans.Initialize() s.Wavetables.Initialize(sampleRate) From 28d2969d5dd4d3509fe736aec57551e79c4ea711 Mon Sep 17 00:00:00 2001 From: Ilja Rotar Date: Fri, 19 Dec 2025 17:21:12 +0100 Subject: [PATCH 05/23] filter fade --- examples/noise.yaml | 1 + module/filter.go | 45 +++++++++++++++++++++++++++++++++++++-------- 2 files changed, 38 insertions(+), 8 deletions(-) diff --git a/examples/noise.yaml b/examples/noise.yaml index e9751c3..e2567a7 100644 --- a/examples/noise.yaml +++ b/examples/noise.yaml @@ -4,6 +4,7 @@ out: lp filters: lp: type: BandPass + fade: 1 freq: 800 width: 20 in: noise diff --git a/module/filter.go b/module/filter.go index 7bba8fd..4388cbb 100644 --- a/module/filter.go +++ b/module/filter.go @@ -10,15 +10,20 @@ import ( type ( Filter struct { Module - Type filterType `yaml:"type"` - Freq float64 `yaml:"freq"` - Width float64 `yaml:"width"` - CV string `yaml:"cv"` - Mod string `yaml:"mod"` - In string `yaml:"in"` + Type filterType `yaml:"type"` + Freq float64 `yaml:"freq"` + Width float64 `yaml:"width"` + CV string `yaml:"cv"` + Mod string `yaml:"mod"` + In string `yaml:"in"` + Fade float64 `yaml:"fade"` + sampleRate float64 a0, a1, a2, b0, b1, b2 float64 inputs filterInputs + + freqFader *fader + widthFader *fader } FilterMap map[string]*Filter @@ -58,8 +63,21 @@ func (f *Filter) initialize(sampleRate float64) error { if err := validateFilterType(f.Type); err != nil { return err } + f.sampleRate = sampleRate f.Freq = calc.Limit(f.Freq, freqRange) + f.Fade = calc.Limit(f.Fade, fadeRange) + + f.freqFader = &fader{ + current: f.Freq, + target: f.Freq, + } + f.widthFader = &fader{ + current: f.Width, + target: f.Width, + } + f.initializeFaders() + f.calculateCoeffs(f.Freq) return nil @@ -71,11 +89,10 @@ func (f *Filter) Update(new *Filter) { } f.Type = new.Type - f.Freq = new.Freq - f.Width = new.Width f.CV = new.CV f.Mod = new.Mod f.In = new.In + f.Fade = new.Fade f.a0 = new.a0 f.a1 = new.a1 @@ -83,6 +100,10 @@ func (f *Filter) Update(new *Filter) { f.b0 = new.b0 f.b1 = new.b1 f.b2 = new.b2 + + f.freqFader.target = new.Freq + f.widthFader.target = new.Width + f.initializeFaders() } func (f *Filter) Step(modules ModuleMap) { @@ -101,6 +122,9 @@ func (f *Filter) Step(modules ModuleMap) { Left: y / 2, Right: y / 2, } + + f.Freq = f.freqFader.fade() + f.Width = f.widthFader.fade() } func (f *Filter) tap(x, freq float64) float64 { @@ -182,6 +206,11 @@ func (f *Filter) calculateBandPassCoeffs(freq, width float64) { f.a2 = 1 - alpha } +func (f *Filter) initializeFaders() { + f.freqFader.initialize(f.Fade, f.sampleRate) + f.widthFader.initialize(f.Fade, f.sampleRate) +} + func getOmega(freq float64, sampleRate float64) float64 { return 2 * math.Pi * (freq / sampleRate) } From 12bf2be882928370c665a737f486d3603fe3ab03 Mon Sep 17 00:00:00 2001 From: Ilja Rotar Date: Fri, 19 Dec 2025 18:33:35 +0100 Subject: [PATCH 06/23] bpm fade --- module/envelope.go | 60 +++++++++++++++++++++++++++++++++----------- module/filter.go | 24 +++++++++++++----- module/gate.go | 30 ++++++++++++++++++---- module/oscillator.go | 24 +++++++++++++----- 4 files changed, 106 insertions(+), 32 deletions(-) diff --git a/module/envelope.go b/module/envelope.go index ab62a2e..e47d63a 100644 --- a/module/envelope.go +++ b/module/envelope.go @@ -80,11 +80,21 @@ func (e *Envelope) Update(new *Envelope) { e.Gate = new.Gate e.Fade = new.Fade - e.attackFader.target = new.Attack - e.decayFader.target = new.Decay - e.releaseFader.target = new.Release - e.peakFader.target = new.Peak - e.levelFader.target = new.Level + if e.attackFader != nil { + e.attackFader.target = new.Attack + } + if e.decayFader != nil { + e.decayFader.target = new.Decay + } + if e.releaseFader != nil { + e.releaseFader.target = new.Release + } + if e.peakFader != nil { + e.peakFader.target = new.Peak + } + if e.levelFader != nil { + e.levelFader.target = new.Level + } e.initializeFaders() } @@ -110,19 +120,39 @@ func (e *Envelope) Step(t float64, modules ModuleMap) { e.gateValue = gateValue - e.Attack = e.attackFader.fade() - e.Decay = e.decayFader.fade() - e.Release = e.releaseFader.fade() - e.Peak = e.peakFader.fade() - e.Level = e.levelFader.fade() + if e.attackFader != nil { + e.Attack = e.attackFader.fade() + } + if e.decayFader != nil { + e.Decay = e.decayFader.fade() + } + if e.releaseFader != nil { + e.Release = e.releaseFader.fade() + } + if e.peakFader != nil { + e.Peak = e.peakFader.fade() + } + if e.levelFader != nil { + e.Level = e.levelFader.fade() + } } func (e *Envelope) initializeFaders() { - e.attackFader.initialize(e.Fade, e.sampleRate) - e.decayFader.initialize(e.Fade, e.sampleRate) - e.releaseFader.initialize(e.Fade, e.sampleRate) - e.peakFader.initialize(e.Peak, e.sampleRate) - e.levelFader.initialize(e.Fade, e.sampleRate) + if e.attackFader != nil { + e.attackFader.initialize(e.Fade, e.sampleRate) + } + if e.decayFader != nil { + e.decayFader.initialize(e.Fade, e.sampleRate) + } + if e.releaseFader != nil { + e.releaseFader.initialize(e.Fade, e.sampleRate) + } + if e.peakFader != nil { + e.peakFader.initialize(e.Peak, e.sampleRate) + } + if e.levelFader != nil { + e.levelFader.initialize(e.Fade, e.sampleRate) + } } func (e *Envelope) getValue(t float64) float64 { diff --git a/module/filter.go b/module/filter.go index 4388cbb..30d58ab 100644 --- a/module/filter.go +++ b/module/filter.go @@ -101,8 +101,12 @@ func (f *Filter) Update(new *Filter) { f.b1 = new.b1 f.b2 = new.b2 - f.freqFader.target = new.Freq - f.widthFader.target = new.Width + if f.freqFader != nil { + f.freqFader.target = new.Freq + } + if f.widthFader != nil { + f.widthFader.target = new.Width + } f.initializeFaders() } @@ -123,8 +127,12 @@ func (f *Filter) Step(modules ModuleMap) { Right: y / 2, } - f.Freq = f.freqFader.fade() - f.Width = f.widthFader.fade() + if f.freqFader != nil { + f.Freq = f.freqFader.fade() + } + if f.widthFader != nil { + f.Width = f.widthFader.fade() + } } func (f *Filter) tap(x, freq float64) float64 { @@ -207,8 +215,12 @@ func (f *Filter) calculateBandPassCoeffs(freq, width float64) { } func (f *Filter) initializeFaders() { - f.freqFader.initialize(f.Fade, f.sampleRate) - f.widthFader.initialize(f.Fade, f.sampleRate) + if f.freqFader != nil { + f.freqFader.initialize(f.Fade, f.sampleRate) + } + if f.widthFader != nil { + f.widthFader.initialize(f.Fade, f.sampleRate) + } } func getOmega(freq float64, sampleRate float64) float64 { diff --git a/module/gate.go b/module/gate.go index af19cb9..3692ef3 100644 --- a/module/gate.go +++ b/module/gate.go @@ -9,12 +9,16 @@ import ( type ( Gate struct { Module - BPM float64 `yaml:"bpm"` - CV string `yaml:"cv"` - Mod string `yaml:"mod"` - Signal []float64 `yaml:"signal"` + BPM float64 `yaml:"bpm"` + CV string `yaml:"cv"` + Mod string `yaml:"mod"` + Signal []float64 `yaml:"signal"` + Fade float64 `yaml:"fade"` + sampleRate float64 idx float64 + + bpmFader *fader } GateMap map[string]*Gate @@ -32,6 +36,13 @@ func (m GateMap) Initialze(sampleRate float64) { func (g *Gate) initialze(sampleRate float64) { g.sampleRate = sampleRate g.BPM = calc.Limit(g.BPM, bpmRange) + g.Fade = calc.Limit(g.Fade, fadeRange) + + g.bpmFader = &fader{ + current: g.BPM, + target: g.BPM, + } + g.bpmFader.initialize(g.Fade, g.sampleRate) for i, val := range g.Signal { if val <= 0 { @@ -47,10 +58,15 @@ func (g *Gate) Update(new *Gate) { return } - g.BPM = new.BPM g.CV = new.CV g.Mod = new.Mod g.Signal = new.Signal + g.Fade = new.Fade + + if g.bpmFader != nil { + g.bpmFader.target = new.BPM + g.bpmFader.initialize(g.Fade, g.sampleRate) + } } func (g *Gate) Step(modules ModuleMap) { @@ -74,6 +90,10 @@ func (g *Gate) Step(modules ModuleMap) { } g.idx += 1 / spb + + if g.bpmFader != nil { + g.BPM = g.bpmFader.fade() + } } func samplesPerBeat(sampleRate, bpm float64) float64 { diff --git a/module/oscillator.go b/module/oscillator.go index 9fe85a0..9678715 100644 --- a/module/oscillator.go +++ b/module/oscillator.go @@ -84,8 +84,12 @@ func (o *Oscillator) Update(new *Oscillator) { o.Fade = new.Fade o.signal = new.signal - o.freqFader.target = new.Freq - o.phaseFader.target = new.Phase + if o.freqFader != nil { + o.freqFader.target = new.Freq + } + if o.phaseFader != nil { + o.phaseFader.target = new.Phase + } o.initializeFaders() } @@ -107,11 +111,19 @@ func (o *Oscillator) Step(modules ModuleMap) { } o.arg += twoPi * freq * mod / o.sampleRate - o.Freq = o.freqFader.fade() - o.Phase = o.phaseFader.fade() + if o.freqFader != nil { + o.Freq = o.freqFader.fade() + } + if o.phaseFader != nil { + o.Phase = o.phaseFader.fade() + } } func (o *Oscillator) initializeFaders() { - o.freqFader.initialize(o.Fade, o.sampleRate) - o.phaseFader.initialize(o.Fade, o.sampleRate) + if o.freqFader != nil { + o.freqFader.initialize(o.Fade, o.sampleRate) + } + if o.phaseFader != nil { + o.phaseFader.initialize(o.Fade, o.sampleRate) + } } From 32833875a81010e4fa00f6201b3a873f9f2cc222 Mon Sep 17 00:00:00 2001 From: Ilja Rotar Date: Fri, 19 Dec 2025 18:49:00 +0100 Subject: [PATCH 07/23] pan fader --- module/gate.go | 2 +- module/pan.go | 36 +++++++++++++++++++++++++++++------- synth/synth.go | 2 +- 3 files changed, 31 insertions(+), 9 deletions(-) diff --git a/module/gate.go b/module/gate.go index 3692ef3..dfc0ebd 100644 --- a/module/gate.go +++ b/module/gate.go @@ -42,7 +42,7 @@ func (g *Gate) initialze(sampleRate float64) { current: g.BPM, target: g.BPM, } - g.bpmFader.initialize(g.Fade, g.sampleRate) + g.bpmFader.initialize(g.Fade, sampleRate) for i, val := range g.Signal { if val <= 0 { diff --git a/module/pan.go b/module/pan.go index 5adc88d..b1b6ef0 100644 --- a/module/pan.go +++ b/module/pan.go @@ -5,25 +5,38 @@ import "github.com/iljarotar/synth/calc" type ( Pan struct { Module - Pan float64 `yaml:"pan"` - Mod string `yaml:"mod"` - In string `yaml:"in"` + Pan float64 `yaml:"pan"` + Mod string `yaml:"mod"` + In string `yaml:"in"` + Fade float64 `yaml:"fade"` + + sampleRate float64 + + panFader *fader } PanMap map[string]*Pan ) -func (m PanMap) Initialize() { +func (m PanMap) Initialize(sampleRate float64) { for _, p := range m { if p == nil { continue } - p.initialize() + p.initialize(sampleRate) } } -func (p *Pan) initialize() { +func (p *Pan) initialize(sampleRate float64) { + p.sampleRate = sampleRate p.Pan = calc.Limit(p.Pan, panRange) + p.Fade = calc.Limit(p.Fade, fadeRange) + + p.panFader = &fader{ + current: p.Pan, + target: p.Pan, + } + p.panFader.initialize(p.Fade, sampleRate) } func (p *Pan) Update(new *Pan) { @@ -31,9 +44,14 @@ func (p *Pan) Update(new *Pan) { return } - p.Pan = new.Pan p.Mod = new.Mod p.In = new.In + p.Fade = new.Fade + + if p.panFader != nil { + p.panFader.target = new.Pan + p.panFader.initialize(p.Fade, p.sampleRate) + } } func (p *Pan) Step(modules ModuleMap) { @@ -46,4 +64,8 @@ func (p *Pan) Step(modules ModuleMap) { Right: in * percent, Left: in * (1 - percent), } + + if p.panFader != nil { + p.Pan = p.panFader.fade() + } } diff --git a/synth/synth.go b/synth/synth.go index 98ca6a4..7887cd0 100644 --- a/synth/synth.go +++ b/synth/synth.go @@ -78,7 +78,7 @@ func (s *Synth) Initialize(sampleRate float64) error { s.Envelopes.Initialize(sampleRate) s.Gates.Initialze(sampleRate) - s.Pans.Initialize() + s.Pans.Initialize(sampleRate) s.Wavetables.Initialize(sampleRate) return nil From 251879aaf0c29b70ee193ec98bc4d169bb054654 Mon Sep 17 00:00:00 2001 From: Ilja Rotar Date: Fri, 19 Dec 2025 22:23:42 +0100 Subject: [PATCH 08/23] wavetable fader --- module/wavetable.go | 30 +++++++++++++++++++++++++----- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/module/wavetable.go b/module/wavetable.go index 2a27da2..b677832 100644 --- a/module/wavetable.go +++ b/module/wavetable.go @@ -9,12 +9,16 @@ import ( type ( Wavetable struct { Module - Freq float64 `yaml:"freq"` - CV string `yaml:"cv"` - Mod string `yaml:"mod"` - Signal []float64 `yaml:"signal"` + Freq float64 `yaml:"freq"` + CV string `yaml:"cv"` + Mod string `yaml:"mod"` + Signal []float64 `yaml:"signal"` + Fade float64 `yaml:"fade"` + sampleRate float64 idx float64 + + freqFader *fader } WavetableMap map[string]*Wavetable @@ -32,6 +36,13 @@ func (m WavetableMap) Initialize(sampleRate float64) { func (w *Wavetable) initialze(sampleRate float64) { w.sampleRate = sampleRate w.Freq = calc.Limit(w.Freq, freqRange) + w.Fade = calc.Limit(w.Fade, fadeRange) + + w.freqFader = &fader{ + current: w.Freq, + target: w.Freq, + } + w.freqFader.initialize(w.Fade, sampleRate) var signal []float64 for _, x := range w.Signal { @@ -45,10 +56,15 @@ func (w *Wavetable) Update(new *Wavetable) { return } - w.Freq = new.Freq w.CV = new.CV w.Mod = new.Mod w.Signal = new.Signal + w.Fade = new.Fade + + if w.freqFader != nil { + w.freqFader.target = new.Freq + w.freqFader.initialize(w.Fade, w.sampleRate) + } } func (w *Wavetable) Step(modules ModuleMap) { @@ -67,4 +83,8 @@ func (w *Wavetable) Step(modules ModuleMap) { mod := math.Pow(2, getMono(modules[w.Mod])) w.idx += freq * mod * float64(length) / w.sampleRate + + if w.freqFader != nil { + w.Freq = w.freqFader.fade() + } } From 3c227d28a3ba1d75d731218c1de4b954ee4399fe Mon Sep 17 00:00:00 2001 From: Ilja Rotar Date: Thu, 1 Jan 2026 17:33:42 +0100 Subject: [PATCH 09/23] mixer fade --- examples/mixer.yaml | 1 + module/mixer.go | 91 ++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 86 insertions(+), 6 deletions(-) diff --git a/examples/mixer.yaml b/examples/mixer.yaml index f97c34f..1ff9588 100644 --- a/examples/mixer.yaml +++ b/examples/mixer.yaml @@ -3,6 +3,7 @@ out: main mixers: main: + fade: 3 gain: 1 in: sine1: 0.25 diff --git a/module/mixer.go b/module/mixer.go index 905806c..9e358fa 100644 --- a/module/mixer.go +++ b/module/mixer.go @@ -9,11 +9,16 @@ import ( type ( Mixer struct { Module - Gain float64 `yaml:"gain"` - CV string `yaml:"cv"` - Mod string `yaml:"mod"` - In map[string]float64 `yaml:"in"` + Gain float64 `yaml:"gain"` + CV string `yaml:"cv"` + Mod string `yaml:"mod"` + In map[string]float64 `yaml:"in"` + Fade float64 `yaml:"fade"` + sampleRate float64 + + gainFader *fader + inputFaders map[string]*fader } MixerMap map[string]*Mixer @@ -34,10 +39,23 @@ func (m MixerMap) Initialize(sampleRate float64) error { func (m *Mixer) initialize(sampleRate float64) error { m.sampleRate = sampleRate m.Gain = calc.Limit(m.Gain, gainRange) + m.Fade = calc.Limit(m.Fade, fadeRange) + m.gainFader = &fader{ + current: m.Gain, + target: m.Gain, + } + + m.inputFaders = map[string]*fader{} for mod, gain := range m.In { m.In[mod] = calc.Limit(gain, gainRange) + + m.inputFaders[mod] = &fader{ + current: gain, + target: gain, + } } + m.initializeFaders() return nil } @@ -47,10 +65,10 @@ func (m *Mixer) Update(new *Mixer) { return } - m.Gain = new.Gain m.CV = new.CV m.Mod = new.Mod - m.In = new.In + m.Fade = new.Fade + m.updateGains(new) } func (m *Mixer) Step(modules ModuleMap) { @@ -81,4 +99,65 @@ func (m *Mixer) Step(modules ModuleMap) { Left: left, Right: right, } + + if m.gainFader != nil { + m.Gain = m.gainFader.fade() + } + + for mod, f := range m.inputFaders { + if f == nil { + continue + } + m.In[mod] = f.fade() + + if f.current == 0 && f.target == 0 { + delete(m.inputFaders, mod) + delete(m.In, mod) + } + } +} + +func (m *Mixer) initializeFaders() { + if m.gainFader != nil { + m.gainFader.initialize(m.Fade, m.sampleRate) + } + + for _, fader := range m.inputFaders { + if fader != nil { + fader.initialize(m.Fade, m.sampleRate) + } + } +} + +func (m *Mixer) updateGains(new *Mixer) { + if m.gainFader != nil { + m.gainFader.target = new.Gain + } + + for mod, gain := range new.In { + f, ok := m.inputFaders[mod] + + if ok && f != nil { + f.target = gain + continue + } + + m.inputFaders[mod] = &fader{ + current: 0, + target: gain, + } + m.In[mod] = 0 + } + + for mod, f := range m.inputFaders { + if f == nil { + continue + } + + if _, ok := new.In[mod]; !ok { + f.target = 0 + } + } + + m.initializeFaders() } From 72aa8b119faeb6f38d78273221f5b10ea31feace Mon Sep 17 00:00:00 2001 From: Ilja Rotar Date: Thu, 1 Jan 2026 17:53:50 +0100 Subject: [PATCH 10/23] fix tests --- Makefile | 2 +- module/mixer.go | 7 +++++++ synth/synth_test.go | 29 ++++++++--------------------- 3 files changed, 16 insertions(+), 22 deletions(-) diff --git a/Makefile b/Makefile index dbaeac6..ca89cf2 100644 --- a/Makefile +++ b/Makefile @@ -6,4 +6,4 @@ build: test PHONY: test test: - go test ./... + go test -cover ./... diff --git a/module/mixer.go b/module/mixer.go index 9e358fa..ecd890a 100644 --- a/module/mixer.go +++ b/module/mixer.go @@ -134,6 +134,13 @@ func (m *Mixer) updateGains(new *Mixer) { m.gainFader.target = new.Gain } + if m.inputFaders == nil { + m.inputFaders = map[string]*fader{} + } + if m.In == nil { + m.In = map[string]float64{} + } + for mod, gain := range new.In { f, ok := m.inputFaders[mod] diff --git a/synth/synth_test.go b/synth/synth_test.go index 10ef11b..878481a 100644 --- a/synth/synth_test.go +++ b/synth/synth_test.go @@ -244,39 +244,30 @@ func TestSynth_Update(t *testing.T) { Volume: 0.5, Envelopes: module.EnvelopeMap{ "env2": { - Gate: "new-gate", - Attack: 1, - Decay: 1, - Release: 1, - Peak: 1, - Level: 1, + Gate: "new-gate", }, }, Filters: module.FilterMap{ "f2": { - In: "new-in", - Type: "BandPass", - Freq: 200, - Width: 10, - CV: "new-cv", - Mod: "new-mod", + In: "new-in", + Type: "BandPass", + CV: "new-cv", + Mod: "new-mod", }, }, Gates: module.GateMap{ "g2": { CV: "new-cv", - BPM: 20, Mod: "new-mod", Signal: []float64{1}, }, }, Mixers: module.MixerMap{ "m2": { - CV: "new-mod", - Gain: 1, - Mod: "new-mod", + CV: "new-mod", + Mod: "new-mod", In: map[string]float64{ - "new-in": 1, + "new-in": 0, }, }, }, @@ -287,15 +278,12 @@ func TestSynth_Update(t *testing.T) { "o2": { Module: module.Module{}, Type: "Sine", - Freq: 200, CV: "new-cv", Mod: "new-mod", - Phase: 0.5, }, }, Pans: module.PanMap{ "p2": { - Pan: 1, Mod: "new-mod", In: "new-in", }, @@ -317,7 +305,6 @@ func TestSynth_Update(t *testing.T) { }, Wavetables: module.WavetableMap{ "w2": { - Freq: 300, CV: "new-cv", Mod: "new-mod", Signal: []float64{1}, From 4ff9649a92794d289e3de838820da7eb92a1dac5 Mon Sep 17 00:00:00 2001 From: Ilja Rotar Date: Sat, 10 Jan 2026 11:00:06 +0100 Subject: [PATCH 11/23] envelope update and fade tests --- module/envelope.go | 25 ++-- module/envelope_test.go | 278 ++++++++++++++++++++++++++++++++++++++++ module/fader.go | 1 + 3 files changed, 293 insertions(+), 11 deletions(-) diff --git a/module/envelope.go b/module/envelope.go index e47d63a..8e1e6f2 100644 --- a/module/envelope.go +++ b/module/envelope.go @@ -119,39 +119,42 @@ func (e *Envelope) Step(t float64, modules ModuleMap) { } e.gateValue = gateValue + e.fade() +} +func (e *Envelope) initializeFaders() { if e.attackFader != nil { - e.Attack = e.attackFader.fade() + e.attackFader.initialize(e.Fade, e.sampleRate) } if e.decayFader != nil { - e.Decay = e.decayFader.fade() + e.decayFader.initialize(e.Fade, e.sampleRate) } if e.releaseFader != nil { - e.Release = e.releaseFader.fade() + e.releaseFader.initialize(e.Fade, e.sampleRate) } if e.peakFader != nil { - e.Peak = e.peakFader.fade() + e.peakFader.initialize(e.Fade, e.sampleRate) } if e.levelFader != nil { - e.Level = e.levelFader.fade() + e.levelFader.initialize(e.Fade, e.sampleRate) } } -func (e *Envelope) initializeFaders() { +func (e *Envelope) fade() { if e.attackFader != nil { - e.attackFader.initialize(e.Fade, e.sampleRate) + e.Attack = e.attackFader.fade() } if e.decayFader != nil { - e.decayFader.initialize(e.Fade, e.sampleRate) + e.Decay = e.decayFader.fade() } if e.releaseFader != nil { - e.releaseFader.initialize(e.Fade, e.sampleRate) + e.Release = e.releaseFader.fade() } if e.peakFader != nil { - e.peakFader.initialize(e.Peak, e.sampleRate) + e.Peak = e.peakFader.fade() } if e.levelFader != nil { - e.levelFader.initialize(e.Fade, e.sampleRate) + e.Level = e.levelFader.fade() } } diff --git a/module/envelope_test.go b/module/envelope_test.go index 692a789..ad0c173 100644 --- a/module/envelope_test.go +++ b/module/envelope_test.go @@ -2,6 +2,8 @@ package module import ( "testing" + + "github.com/google/go-cmp/cmp" ) func TestEnvelope_attack(t *testing.T) { @@ -417,3 +419,279 @@ func Test_linear(t *testing.T) { }) } } + +func TestEnvelope_Update(t *testing.T) { + sampleRate := 44100.0 + + tests := []struct { + name string + e *Envelope + new *Envelope + want *Envelope + }{ + { + name: "new is nil", + e: &Envelope{ + Gate: "gate", + }, + new: nil, + want: &Envelope{ + Gate: "gate", + }, + }, + { + name: "update all", + e: &Envelope{ + Module: Module{ + current: Output{ + Mono: 1, + }, + }, + Attack: 1, + Decay: 1, + Release: 1, + Peak: 1, + Level: 1, + Gate: "gate", + Fade: 1, + triggeredAt: 1, + releasedAt: 2, + gateValue: -1, + level: 1, + sampleRate: 44100, + attackFader: &fader{ + current: 1, + target: 1, + step: 0, + }, + decayFader: &fader{ + current: 1, + target: 1, + step: 0, + }, + releaseFader: &fader{ + current: 1, + target: 1, + step: 0, + }, + peakFader: &fader{ + current: 1, + target: 1, + step: 0, + }, + levelFader: &fader{ + current: 1, + target: 1, + step: 0, + }, + }, + new: &Envelope{ + Attack: 2, + Decay: 2, + Release: 2, + Peak: 2, + Level: 2, + Gate: "new-gate", + Fade: 2, + }, + want: &Envelope{ + Module: Module{ + current: Output{ + Mono: 1, + }, + }, + Attack: 1, + Decay: 1, + Release: 1, + Peak: 1, + Level: 1, + Gate: "new-gate", + Fade: 2, + triggeredAt: 1, + releasedAt: 2, + gateValue: -1, + level: 1, + sampleRate: sampleRate, + attackFader: &fader{ + current: 1, + target: 2, + step: 0.5 / sampleRate, + }, + decayFader: &fader{ + current: 1, + target: 2, + step: 0.5 / sampleRate, + }, + releaseFader: &fader{ + current: 1, + target: 2, + step: 0.5 / sampleRate, + }, + peakFader: &fader{ + current: 1, + target: 2, + step: 0.5 / sampleRate, + }, + levelFader: &fader{ + current: 1, + target: 2, + step: 0.5 / sampleRate, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.e.Update(tt.new) + if diff := cmp.Diff(tt.want, tt.e, cmp.AllowUnexported(Module{}, Envelope{}, fader{})); diff != "" { + t.Errorf("Envelope.Update() diff = %s", diff) + } + }) + } +} + +func TestEnvelope_fade(t *testing.T) { + tests := []struct { + name string + e *Envelope + want *Envelope + }{ + { + name: "no fade necessary", + e: &Envelope{ + Attack: 1, + Decay: 1, + Release: 1, + Peak: 1, + Level: 1, + attackFader: &fader{ + current: 1, + target: 1, + step: 0.5, // step must be ignored when target is reached + }, + decayFader: &fader{ + current: 1, + target: 1, + step: 0.5, // step must be ignored when target is reached + }, + releaseFader: &fader{ + current: 1, + target: 1, + step: 0.5, // step must be ignored when target is reached + }, + peakFader: &fader{ + current: 1, + target: 1, + step: 0.5, // step must be ignored when target is reached + }, + levelFader: &fader{ + current: 1, + target: 1, + step: 0.5, // step must be ignored when target is reached + }, + }, + want: &Envelope{ + Attack: 1, + Decay: 1, + Release: 1, + Peak: 1, + Level: 1, + attackFader: &fader{ + current: 1, + target: 1, + }, + decayFader: &fader{ + current: 1, + target: 1, + }, + releaseFader: &fader{ + current: 1, + target: 1, + }, + peakFader: &fader{ + current: 1, + target: 1, + }, + levelFader: &fader{ + current: 1, + target: 1, + }, + }, + }, + { + name: "fade all parameters", + e: &Envelope{ + Attack: 1, + Decay: 1, + Release: 1, + Peak: 1, + Level: 1, + attackFader: &fader{ + current: 1, + target: 2, + step: 0.1, + }, + decayFader: &fader{ + current: 1, + target: 2, + step: 0.1, + }, + releaseFader: &fader{ + current: 1, + target: 2, + step: 0.1, + }, + peakFader: &fader{ + current: 1, + target: 2, + step: 0.1, + }, + levelFader: &fader{ + current: 1, + target: 2, + step: 0.1, + }, + }, + want: &Envelope{ + Attack: 1.1, + Decay: 1.1, + Release: 1.1, + Peak: 1.1, + Level: 1.1, + attackFader: &fader{ + current: 1.1, + target: 2, + step: 0.1, + }, + decayFader: &fader{ + current: 1.1, + target: 2, + step: 0.1, + }, + releaseFader: &fader{ + current: 1.1, + target: 2, + step: 0.1, + }, + peakFader: &fader{ + current: 1.1, + target: 2, + step: 0.1, + }, + levelFader: &fader{ + current: 1.1, + target: 2, + step: 0.1, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.e.fade() + if diff := cmp.Diff(tt.want, tt.e, cmp.AllowUnexported(Module{}, Envelope{}, fader{})); diff != "" { + t.Errorf("Envelope.fade() diff = %s", diff) + } + }) + } +} diff --git a/module/fader.go b/module/fader.go index 53cdb37..3b36eb5 100644 --- a/module/fader.go +++ b/module/fader.go @@ -20,6 +20,7 @@ func (f *fader) initialize(duration, sampleRate float64) { func (f *fader) fade() float64 { // technically, this case is covered below, but for efficiency we make an early return here if f.current == f.target { + f.step = 0 return f.current } From 440b034eafb5168d6fc40df9055afe30aec3d1e1 Mon Sep 17 00:00:00 2001 From: Ilja Rotar Date: Sat, 10 Jan 2026 11:19:53 +0100 Subject: [PATCH 12/23] filter tests --- module/filter.go | 18 +-- module/filter_test.go | 278 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 289 insertions(+), 7 deletions(-) create mode 100644 module/filter_test.go diff --git a/module/filter.go b/module/filter.go index 30d58ab..84ebce1 100644 --- a/module/filter.go +++ b/module/filter.go @@ -40,7 +40,7 @@ const ( filterTypeBandPass filterType = "BandPass" gain = -50 - slope = 0.99 // how is this related to width? + slope = 0.99 ) var ( @@ -127,12 +127,7 @@ func (f *Filter) Step(modules ModuleMap) { Right: y / 2, } - if f.freqFader != nil { - f.Freq = f.freqFader.fade() - } - if f.widthFader != nil { - f.Width = f.widthFader.fade() - } + f.fade() } func (f *Filter) tap(x, freq float64) float64 { @@ -223,6 +218,15 @@ func (f *Filter) initializeFaders() { } } +func (f *Filter) fade() { + if f.freqFader != nil { + f.Freq = f.freqFader.fade() + } + if f.widthFader != nil { + f.Width = f.widthFader.fade() + } +} + func getOmega(freq float64, sampleRate float64) float64 { return 2 * math.Pi * (freq / sampleRate) } diff --git a/module/filter_test.go b/module/filter_test.go new file mode 100644 index 0000000..9ec0f8b --- /dev/null +++ b/module/filter_test.go @@ -0,0 +1,278 @@ +package module + +import ( + "testing" + + "github.com/google/go-cmp/cmp" +) + +func TestFilter_Update(t *testing.T) { + sampleRate := 44100.0 + + tests := []struct { + name string + f *Filter + new *Filter + want *Filter + }{ + { + name: "no update necessary", + f: &Filter{ + Module: Module{ + current: Output{ + Mono: 1, + }, + }, + Type: "BandPass", + Freq: 440, + Width: 50, + CV: "cv", + Mod: "mod", + In: "in", + Fade: 1, + sampleRate: sampleRate, + a0: 1, + a1: 1, + a2: 1, + b0: 1, + b1: 1, + b2: 1, + inputs: filterInputs{ + x0: 1, + x1: 1, + x2: 1, + y0: 1, + y1: 1, + }, + freqFader: &fader{ + current: 440, + target: 440, + step: 0, + }, + widthFader: &fader{ + current: 50, + target: 50, + step: 0, + }, + }, + new: nil, + want: &Filter{ + Module: Module{ + current: Output{ + Mono: 1, + }, + }, + Type: "BandPass", + Freq: 440, + Width: 50, + CV: "cv", + Mod: "mod", + In: "in", + Fade: 1, + sampleRate: sampleRate, + a0: 1, + a1: 1, + a2: 1, + b0: 1, + b1: 1, + b2: 1, + inputs: filterInputs{ + x0: 1, + x1: 1, + x2: 1, + y0: 1, + y1: 1, + }, + freqFader: &fader{ + current: 440, + target: 440, + step: 0, + }, + widthFader: &fader{ + current: 50, + target: 50, + step: 0, + }, + }, + }, + { + name: "update all", + f: &Filter{ + Module: Module{ + current: Output{ + Mono: 1, + }, + }, + Type: "BandPass", + Freq: 440, + Width: 50, + CV: "cv", + Mod: "mod", + In: "in", + Fade: 1, + sampleRate: sampleRate, + a0: 1, + a1: 1, + a2: 1, + b0: 1, + b1: 1, + b2: 1, + inputs: filterInputs{ + x0: 1, + x1: 1, + x2: 1, + y0: 1, + y1: 1, + }, + freqFader: &fader{ + current: 440, + target: 440, + step: 0, + }, + widthFader: &fader{ + current: 50, + target: 50, + step: 0, + }, + }, + new: &Filter{ + Type: "LowPass", + Freq: 220, + Width: 0, + CV: "new-cv", + Mod: "new-mod", + In: "new-in", + Fade: 2, + a0: 2, + a1: 2, + a2: 2, + b0: 2, + b1: 2, + b2: 2, + }, + want: &Filter{ + Module: Module{ + current: Output{ + Mono: 1, + }, + }, + Type: "LowPass", + Freq: 440, + Width: 50, + CV: "new-cv", + Mod: "new-mod", + In: "new-in", + Fade: 2, + sampleRate: sampleRate, + a0: 2, + a1: 2, + a2: 2, + b0: 2, + b1: 2, + b2: 2, + inputs: filterInputs{ + x0: 1, + x1: 1, + x2: 1, + y0: 1, + y1: 1, + }, + freqFader: &fader{ + current: 440, + target: 220, + step: -110 / sampleRate, + }, + widthFader: &fader{ + current: 50, + target: 0, + step: -25 / sampleRate, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.f.Update(tt.new) + if diff := cmp.Diff(tt.want, tt.f, cmp.AllowUnexported(Module{}, Filter{}, fader{}, filterInputs{})); diff != "" { + t.Errorf("Filter.Update() diff = %s", diff) + } + }) + } +} + +func TestFilter_fade(t *testing.T) { + tests := []struct { + name string + f *Filter + want *Filter + }{ + { + name: "no fade necessary", + f: &Filter{ + Freq: 440, + Width: 50, + freqFader: &fader{ + current: 440, + target: 440, + step: 25, + }, + widthFader: &fader{ + current: 50, + target: 50, + step: 10, + }, + }, + want: &Filter{ + Freq: 440, + Width: 50, + freqFader: &fader{ + current: 440, + target: 440, + }, + widthFader: &fader{ + current: 50, + target: 50, + }, + }, + }, + { + name: "fade all", + f: &Filter{ + Freq: 440, + Width: 50, + freqFader: &fader{ + current: 440, + target: 220, + step: -20, + }, + widthFader: &fader{ + current: 50, + target: 0, + step: -10, + }, + }, + want: &Filter{ + Freq: 420, + Width: 40, + freqFader: &fader{ + current: 420, + target: 220, + step: -20, + }, + widthFader: &fader{ + current: 40, + target: 0, + step: -10, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.f.fade() + if diff := cmp.Diff(tt.want, tt.f, cmp.AllowUnexported(Module{}, Filter{}, fader{}, filterInputs{})); diff != "" { + t.Errorf("Filter.fade() diff = %s", diff) + } + }) + } +} From 3796abeea095a25868a7bfe6d4c2568853baee74 Mon Sep 17 00:00:00 2001 From: Ilja Rotar Date: Sat, 10 Jan 2026 11:20:06 +0100 Subject: [PATCH 13/23] envelope cleanup --- module/envelope.go | 60 ++++++++++++++++++++--------------------- module/envelope_test.go | 10 +++---- 2 files changed, 35 insertions(+), 35 deletions(-) diff --git a/module/envelope.go b/module/envelope.go index 8e1e6f2..b01afdc 100644 --- a/module/envelope.go +++ b/module/envelope.go @@ -122,6 +122,36 @@ func (e *Envelope) Step(t float64, modules ModuleMap) { e.fade() } +func (e *Envelope) getValue(t float64) float64 { + if e.releasedAt >= e.triggeredAt { + if t-e.releasedAt > e.Release { + return 0 + } + return e.release(t) + } + + switch { + case t-e.triggeredAt < e.Attack: + return e.attack(t) + case t-e.triggeredAt < e.Attack+e.Decay: + return e.decay(t) + default: + return e.Level + } +} + +func (e *Envelope) attack(t float64) float64 { + start := e.triggeredAt + end := start + e.Attack + return linear(start, end, 0, e.Peak, t) +} + +func (e *Envelope) decay(t float64) float64 { + start := e.triggeredAt + e.Attack + end := start + e.Decay + return linear(start, end, e.Peak, e.Level, t) +} + func (e *Envelope) initializeFaders() { if e.attackFader != nil { e.attackFader.initialize(e.Fade, e.sampleRate) @@ -158,36 +188,6 @@ func (e *Envelope) fade() { } } -func (e *Envelope) getValue(t float64) float64 { - if e.releasedAt >= e.triggeredAt { - if t-e.releasedAt > e.Release { - return 0 - } - return e.release(t) - } - - switch { - case t-e.triggeredAt < e.Attack: - return e.attack(t) - case t-e.triggeredAt < e.Attack+e.Decay: - return e.decay(t) - default: - return e.Level - } -} - -func (e *Envelope) attack(t float64) float64 { - start := e.triggeredAt - end := start + e.Attack - return linear(start, end, 0, e.Peak, t) -} - -func (e *Envelope) decay(t float64) float64 { - start := e.triggeredAt + e.Attack - end := start + e.Decay - return linear(start, end, e.Peak, e.Level, t) -} - func (e *Envelope) release(t float64) float64 { start := e.releasedAt end := start + e.Release diff --git a/module/envelope_test.go b/module/envelope_test.go index ad0c173..01cd02e 100644 --- a/module/envelope_test.go +++ b/module/envelope_test.go @@ -567,27 +567,27 @@ func TestEnvelope_fade(t *testing.T) { attackFader: &fader{ current: 1, target: 1, - step: 0.5, // step must be ignored when target is reached + step: 0.5, }, decayFader: &fader{ current: 1, target: 1, - step: 0.5, // step must be ignored when target is reached + step: 0.5, }, releaseFader: &fader{ current: 1, target: 1, - step: 0.5, // step must be ignored when target is reached + step: 0.5, }, peakFader: &fader{ current: 1, target: 1, - step: 0.5, // step must be ignored when target is reached + step: 0.5, }, levelFader: &fader{ current: 1, target: 1, - step: 0.5, // step must be ignored when target is reached + step: 0.5, }, }, want: &Envelope{ From 77b960f89fee55d63c4c41f64fd9020ef8251d9f Mon Sep 17 00:00:00 2001 From: Ilja Rotar Date: Sat, 10 Jan 2026 11:29:36 +0100 Subject: [PATCH 14/23] gate tests --- module/gate.go | 3 + module/gate_test.go | 164 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 167 insertions(+) diff --git a/module/gate.go b/module/gate.go index dfc0ebd..630432c 100644 --- a/module/gate.go +++ b/module/gate.go @@ -90,7 +90,10 @@ func (g *Gate) Step(modules ModuleMap) { } g.idx += 1 / spb + g.fade() +} +func (g *Gate) fade() { if g.bpmFader != nil { g.BPM = g.bpmFader.fade() } diff --git a/module/gate_test.go b/module/gate_test.go index db06873..789e585 100644 --- a/module/gate_test.go +++ b/module/gate_test.go @@ -153,3 +153,167 @@ func Test_samplesPerBeat(t *testing.T) { }) } } + +func TestGate_Update(t *testing.T) { + sampleRate := 44100.0 + + tests := []struct { + name string + g *Gate + new *Gate + want *Gate + }{ + { + name: "no update necessary", + g: &Gate{ + Module: Module{ + current: Output{ + Mono: 1, + }, + }, + BPM: 50, + CV: "cv", + Mod: "mod", + Signal: []float64{1, 0}, + Fade: 1, + sampleRate: sampleRate, + idx: 1, + bpmFader: &fader{ + current: 50, + target: 50, + step: 1, + }, + }, + new: nil, + want: &Gate{ + Module: Module{ + current: Output{ + Mono: 1, + }, + }, + BPM: 50, + CV: "cv", + Mod: "mod", + Signal: []float64{1, 0}, + Fade: 1, + sampleRate: sampleRate, + idx: 1, + bpmFader: &fader{ + current: 50, + target: 50, + step: 1, + }, + }, + }, + { + name: "update all", + g: &Gate{ + Module: Module{ + current: Output{ + Mono: 1, + }, + }, + BPM: 50, + CV: "cv", + Mod: "mod", + Signal: []float64{1, 0}, + Fade: 1, + sampleRate: sampleRate, + idx: 1, + bpmFader: &fader{ + current: 50, + target: 50, + step: 1, + }, + }, + new: &Gate{ + BPM: 100, + CV: "new-cv", + Mod: "new-mod", + Signal: []float64{0, 1, 0, 1}, + Fade: 2, + }, + want: &Gate{ + Module: Module{ + current: Output{ + Mono: 1, + }, + }, + BPM: 50, + CV: "new-cv", + Mod: "new-mod", + Signal: []float64{0, 1, 0, 1}, + Fade: 2, + sampleRate: sampleRate, + idx: 1, + bpmFader: &fader{ + current: 50, + target: 100, + step: 25 / sampleRate, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.g.Update(tt.new) + if diff := cmp.Diff(tt.want, tt.g, cmp.AllowUnexported(Module{}, Gate{}, fader{})); diff != "" { + t.Errorf("Gate.Update() diff = %s", diff) + } + }) + } +} + +func TestGate_fade(t *testing.T) { + tests := []struct { + name string + g *Gate + want *Gate + }{ + { + name: "no fade necessary", + g: &Gate{ + BPM: 50, + bpmFader: &fader{ + current: 50, + target: 50, + step: 1, + }, + }, + want: &Gate{ + BPM: 50, + bpmFader: &fader{ + current: 50, + target: 50, + }, + }, + }, + { + name: "fade", + g: &Gate{ + BPM: 50, + bpmFader: &fader{ + current: 50, + target: 100, + step: 1, + }, + }, + want: &Gate{ + BPM: 51, + bpmFader: &fader{ + current: 51, + target: 100, + step: 1, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.g.fade() + if diff := cmp.Diff(tt.want, tt.g, cmp.AllowUnexported(Module{}, Gate{}, fader{})); diff != "" { + t.Errorf("Gate.fade() diff = %s", diff) + } + }) + } +} From da00f4dd73e42442241556c7e4da39a80d404de5 Mon Sep 17 00:00:00 2001 From: Ilja Rotar Date: Sat, 10 Jan 2026 11:53:29 +0100 Subject: [PATCH 15/23] readme --- README.md | 37 ++++++++++++++++++++++++++++++++++--- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index d346f54..0691890 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,3 @@ - - # Synth A modular synthesizer for the command line written in [golang](https://go.dev/). @@ -27,7 +25,12 @@ cp bin/synth /usr/local/bin # or somewhere else in your PATH Patches for the modular synthesizer are provided in [yaml](https://yaml.org/) format. A patch contains configurations for all modules that you want the synthesizer to play. -When you modify and save a patch while it is being played the synthesizer will reload the file. +When you modify and save a patch during playback the synthesizer will reload the file. +Since changing a parameter like volume or frequency by a large amount too quickly results in a clipping noise, most modules allow configuring a `fade` parameter that controls how long it takes for the module's parameters to transition from the previous value to the new one. +Such a fade-over is not only useful to avoid clipping sounds but can also be utilised to create slow transitions in the music. +Say, for example, you want to slowly fade in one module while slowly fading out another. +You can add a `fade` parameter to the mixer that controls both modules' volumes—e.g. `fade: 5` for 5 seconds—and change the new module's volume to a positive value and the other one's to `0`. +Then save the file and the transition will start. ### Patch Files @@ -91,6 +94,10 @@ envelopes: # when gate output changes from positive to negative or zero the envelope is released gate: name-of-gate-module + # fade controls the transition length in seconds + # affected parameters are `attack`, `decay`, `release`, `peak` and `level` + fade: 2 + # filters of type low pass, high pass or band pass filters: # the unique module name to be used as a reference in other modules @@ -115,6 +122,10 @@ filters: # name of the module whose output will be filtered in: name-of-input-module + # fade controls the transition length in seconds + # affected parameters are `freq` and `width` + fade: 2 + # gates can be used as gates for envelopes or sequencers or as triggers for samplers. gates: # the unique module name to be used as a reference in other modules @@ -132,6 +143,10 @@ gates: # each negative or zero value will be mapped to `-1`, each positive to `1` signal: [1, 0, 0, 1, 0, 1, 1, 0, 1, 0] + # fade controls the transition length in seconds + # affected parameter is `bpm` + fade: 2 + # mixers combine outputs of multiple modules and control their output levels mixers: # the unique module name to be used as a reference in other modules @@ -151,6 +166,10 @@ mixers: name-of-first-module: 0.5 name-of-second-module: 0.25 + # fade controls the transition length in seconds + # affected parameters are `gain` as well as all input modules' gain levels + fade: 2 + # noise modules simple output random values noises: # the unique module name to be used as a reference in other modules @@ -177,6 +196,10 @@ oscillators: # range `[-1, 1]` phase: 0.75 + # fade controls the transition length in seconds + # affected parameters are `freq` and `phase` + fade: 2 + # pan modules are used to add stereo balance pans: # the unique module name to be used as a reference in other modules @@ -192,6 +215,10 @@ pans: # modulator for `pan` mod: name-of-mod + # fade controls the transition length in seconds + # affected parameter is `pan` + fade: 2 + # sample and hold modules samplers: # the unique module name to be used as a reference in other modules @@ -243,6 +270,10 @@ wavetables: # an arbitrary signal # the signal can have any length signal: [-1, 0, 0.25, -0.3, 0.8, 1] + + # fade controls the transition length in seconds + # affected parameter is `freq` + fade: 2 ``` ### Configuration From 0d7ea523b1605bf02488c60d749eba7f400b44d1 Mon Sep 17 00:00:00 2001 From: Ilja Rotar Date: Sat, 10 Jan 2026 12:37:52 +0100 Subject: [PATCH 16/23] mixer tests --- module/mixer.go | 4 + module/mixer_test.go | 273 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 277 insertions(+) diff --git a/module/mixer.go b/module/mixer.go index ecd890a..530b8c2 100644 --- a/module/mixer.go +++ b/module/mixer.go @@ -100,6 +100,10 @@ func (m *Mixer) Step(modules ModuleMap) { Right: right, } + m.fade() +} + +func (m *Mixer) fade() { if m.gainFader != nil { m.Gain = m.gainFader.fade() } diff --git a/module/mixer_test.go b/module/mixer_test.go index 776d65a..4fafbcc 100644 --- a/module/mixer_test.go +++ b/module/mixer_test.go @@ -160,3 +160,276 @@ func TestMixer_Step(t *testing.T) { }) } } + +func TestMixer_Update(t *testing.T) { + sampleRate := 44100.0 + + tests := []struct { + name string + m *Mixer + new *Mixer + want *Mixer + }{ + { + name: "no update necessary", + m: &Mixer{ + Module: Module{ + current: Output{ + Mono: 1, + }, + }, + Gain: 1, + CV: "cv", + Mod: "mod", + In: map[string]float64{ + "in1": 1, + }, + Fade: 1, + sampleRate: sampleRate, + gainFader: &fader{ + current: 1, + target: 1, + step: 0.5, + }, + inputFaders: map[string]*fader{ + "in1": { + current: 1, + target: 1, + step: 0.5, + }, + }, + }, + new: nil, + want: &Mixer{ + Module: Module{ + current: Output{ + Mono: 1, + }, + }, + Gain: 1, + CV: "cv", + Mod: "mod", + In: map[string]float64{ + "in1": 1, + }, + Fade: 1, + sampleRate: sampleRate, + gainFader: &fader{ + current: 1, + target: 1, + step: 0.5, + }, + inputFaders: map[string]*fader{ + "in1": { + current: 1, + target: 1, + step: 0.5, + }, + }, + }, + }, + { + name: "update all", + m: &Mixer{ + Module: Module{ + current: Output{ + Mono: 1, + }, + }, + Gain: 1, + CV: "cv", + Mod: "mod", + In: map[string]float64{ + "in1": 1, + "in2": 1, + }, + Fade: 1, + sampleRate: sampleRate, + gainFader: &fader{ + current: 1, + target: 1, + step: 0.5, + }, + inputFaders: map[string]*fader{ + "in1": { + current: 1, + target: 1, + step: 0.5, + }, + "in2": { + current: 1, + target: 1, + step: 0.5, + }, + }, + }, + new: &Mixer{ + Gain: 0.5, + CV: "new-cv", + Mod: "new-mod", + In: map[string]float64{ + "in1": 0.5, + "in3": 0.5, + }, + Fade: 2, + }, + want: &Mixer{ + Module: Module{ + current: Output{ + Mono: 1, + }, + }, + Gain: 1, + CV: "new-cv", + Mod: "new-mod", + In: map[string]float64{ + "in1": 1, + "in2": 1, + "in3": 0, + }, + Fade: 2, + sampleRate: sampleRate, + gainFader: &fader{ + current: 1, + target: 0.5, + step: -0.25 / sampleRate, + }, + inputFaders: map[string]*fader{ + "in1": { + current: 1, + target: 0.5, + step: -0.25 / sampleRate, + }, + "in2": { + current: 1, + target: 0, + step: -0.5 / sampleRate, + }, + "in3": { + current: 0, + target: 0.5, + step: 0.25 / sampleRate, + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.m.Update(tt.new) + if diff := cmp.Diff(tt.want, tt.m, cmp.AllowUnexported(Module{}, Mixer{}, fader{})); diff != "" { + t.Errorf("Mixer.Update() diff = %s", diff) + } + }) + } +} + +func TestMixer_fade(t *testing.T) { + tests := []struct { + name string + m *Mixer + want *Mixer + }{ + { + name: "no fade necessary", + m: &Mixer{ + Gain: 1, + In: map[string]float64{ + "in1": 1, + "in2": 1, + }, + gainFader: &fader{ + current: 1, + target: 1, + step: 0.5, + }, + inputFaders: map[string]*fader{ + "in1": { + current: 1, + target: 1, + step: 0.5, + }, + "in2": { + current: 1, + target: 1, + step: 0.5, + }, + }, + }, + want: &Mixer{ + Gain: 1, + In: map[string]float64{ + "in1": 1, + "in2": 1, + }, + gainFader: &fader{ + current: 1, + target: 1, + }, + inputFaders: map[string]*fader{ + "in1": { + current: 1, + target: 1, + }, + "in2": { + current: 1, + target: 1, + }, + }, + }, + }, + { + name: "fade all", + m: &Mixer{ + Gain: 1, + In: map[string]float64{ + "in1": 1, + "in2": 0.1, + }, + gainFader: &fader{ + current: 1, + target: 0.5, + step: -0.1, + }, + inputFaders: map[string]*fader{ + "in1": { + current: 1, + target: 0.5, + step: -0.2, + }, + "in2": { + current: 0.1, + target: 0, + step: -0.2, + }, + }, + }, + want: &Mixer{ + Gain: 0.9, + In: map[string]float64{ + "in1": 0.8, + }, + gainFader: &fader{ + current: 0.9, + target: 0.5, + step: -0.1, + }, + inputFaders: map[string]*fader{ + "in1": { + current: 0.8, + target: 0.5, + step: -0.2, + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.m.fade() + if diff := cmp.Diff(tt.want, tt.m, cmp.AllowUnexported(Module{}, Mixer{}, fader{})); diff != "" { + t.Errorf("Mixer.fade() diff = %s", diff) + } + }) + } +} From 7d6efe71aaf1783215baf518807ea6842409400c Mon Sep 17 00:00:00 2001 From: Ilja Rotar Date: Sat, 10 Jan 2026 12:48:57 +0100 Subject: [PATCH 17/23] osc tests --- module/oscillator.go | 4 + module/oscillator_test.go | 214 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 218 insertions(+) diff --git a/module/oscillator.go b/module/oscillator.go index 9678715..0741665 100644 --- a/module/oscillator.go +++ b/module/oscillator.go @@ -111,6 +111,10 @@ func (o *Oscillator) Step(modules ModuleMap) { } o.arg += twoPi * freq * mod / o.sampleRate + o.fade() +} + +func (o *Oscillator) fade() { if o.freqFader != nil { o.Freq = o.freqFader.fade() } diff --git a/module/oscillator_test.go b/module/oscillator_test.go index 4e06525..8c44db5 100644 --- a/module/oscillator_test.go +++ b/module/oscillator_test.go @@ -3,6 +3,8 @@ package module import ( "math" "testing" + + "github.com/google/go-cmp/cmp" ) func TestOscillator_Step(t *testing.T) { @@ -107,3 +109,215 @@ func TestOscillator_Step(t *testing.T) { }) } } + +func TestOscillator_Update(t *testing.T) { + sampleRate := 44100.0 + + tests := []struct { + name string + o *Oscillator + new *Oscillator + want *Oscillator + }{ + { + name: "no update necessary", + o: &Oscillator{ + Module: Module{ + current: Output{ + Mono: 1, + }, + }, + Type: "Sine", + Freq: 440, + CV: "cv", + Mod: "mod", + Phase: 0.5, + Fade: 1, + sampleRate: sampleRate, + arg: 2, + freqFader: &fader{ + current: 440, + target: 440, + step: 12, + }, + phaseFader: &fader{ + current: 0.5, + target: 0.5, + step: 0.1, + }, + }, + new: nil, + want: &Oscillator{ + Module: Module{ + current: Output{ + Mono: 1, + }, + }, + Type: "Sine", + Freq: 440, + CV: "cv", + Mod: "mod", + Phase: 0.5, + Fade: 1, + sampleRate: sampleRate, + arg: 2, + freqFader: &fader{ + current: 440, + target: 440, + step: 12, + }, + phaseFader: &fader{ + current: 0.5, + target: 0.5, + step: 0.1, + }, + }, + }, + { + name: "update all", + o: &Oscillator{ + Module: Module{ + current: Output{ + Mono: 1, + }, + }, + Type: "Sine", + Freq: 440, + CV: "cv", + Mod: "mod", + Phase: 0.5, + Fade: 1, + sampleRate: sampleRate, + arg: 2, + freqFader: &fader{ + current: 440, + target: 440, + step: 12, + }, + phaseFader: &fader{ + current: 0.5, + target: 0.5, + step: 0.1, + }, + }, + new: &Oscillator{ + Type: "Square", + Freq: 220, + CV: "new-cv", + Mod: "new-mod", + Phase: 0, + Fade: 2, + }, + want: &Oscillator{ + Module: Module{ + current: Output{ + Mono: 1, + }, + }, + Type: "Square", + Freq: 440, + CV: "new-cv", + Mod: "new-mod", + Phase: 0.5, + Fade: 2, + sampleRate: sampleRate, + arg: 2, + freqFader: &fader{ + current: 440, + target: 220, + step: -110 / sampleRate, + }, + phaseFader: &fader{ + current: 0.5, + target: 0, + step: -0.25 / sampleRate, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.o.Update(tt.new) + if diff := cmp.Diff(tt.want, tt.o, cmp.AllowUnexported(Module{}, Oscillator{}, fader{})); diff != "" { + t.Errorf("Oscillator.Update() diff = %s", diff) + } + }) + } +} + +func TestOscillator_fade(t *testing.T) { + tests := []struct { + name string + o *Oscillator + want *Oscillator + }{ + { + name: "no fade necessary", + o: &Oscillator{ + Freq: 440, + Phase: 0.5, + freqFader: &fader{ + current: 440, + target: 440, + step: 22, + }, + phaseFader: &fader{ + current: 0.5, + target: 0.5, + step: 0.5, + }, + }, + want: &Oscillator{ + Freq: 440, + Phase: 0.5, + freqFader: &fader{ + current: 440, + target: 440, + }, + phaseFader: &fader{ + current: 0.5, + target: 0.5, + }, + }, + }, + { + name: "fade all", + o: &Oscillator{ + Freq: 440, + Phase: 0.5, + freqFader: &fader{ + current: 440, + target: 800, + step: 20, + }, + phaseFader: &fader{ + current: 0.5, + target: 0.75, + step: 0.1, + }, + }, + want: &Oscillator{ + Freq: 460, + Phase: 0.6, + freqFader: &fader{ + current: 460, + target: 800, + step: 20, + }, + phaseFader: &fader{ + current: 0.6, + target: 0.75, + step: 0.1, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.o.fade() + if diff := cmp.Diff(tt.want, tt.o, cmp.AllowUnexported(Module{}, Oscillator{}, fader{})); diff != "" { + t.Errorf("Oscillator.fade() diff = %s", diff) + } + }) + } +} From b6de906f3ea15dcf2685bf753e90556aa3c6b382 Mon Sep 17 00:00:00 2001 From: Ilja Rotar Date: Sat, 10 Jan 2026 12:58:40 +0100 Subject: [PATCH 18/23] fix pan docs --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 0691890..8530b6d 100644 --- a/README.md +++ b/README.md @@ -209,8 +209,8 @@ pans: # a value of `-1` places the signal completely to the left, `1` places it to the right pan: -0.5 - # cv for `pan` - cv: name-of-cv + # name of the module whose output should be stereo balanced + in: name-of-input-module # modulator for `pan` mod: name-of-mod From 050a0d2a4804e667506ae98b1014199640208c09 Mon Sep 17 00:00:00 2001 From: Ilja Rotar Date: Sat, 10 Jan 2026 13:01:51 +0100 Subject: [PATCH 19/23] pan tests --- module/pan.go | 4 ++ module/pan_test.go | 155 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 159 insertions(+) diff --git a/module/pan.go b/module/pan.go index b1b6ef0..4fdcb92 100644 --- a/module/pan.go +++ b/module/pan.go @@ -65,6 +65,10 @@ func (p *Pan) Step(modules ModuleMap) { Left: in * (1 - percent), } + p.fade() +} + +func (p *Pan) fade() { if p.panFader != nil { p.Pan = p.panFader.fade() } diff --git a/module/pan_test.go b/module/pan_test.go index 8e14290..ec246f6 100644 --- a/module/pan_test.go +++ b/module/pan_test.go @@ -68,3 +68,158 @@ func TestPan_Step(t *testing.T) { }) } } + +func TestPan_Update(t *testing.T) { + sampleRate := 44100.0 + + tests := []struct { + name string + p *Pan + new *Pan + want *Pan + }{ + { + name: "no udpate necessary", + p: &Pan{ + Module: Module{ + current: Output{ + Mono: 1, + }, + }, + Pan: 0.5, + Mod: "mod", + In: "in", + Fade: 1, + sampleRate: sampleRate, + panFader: &fader{ + current: 0.5, + target: 0.5, + step: 0.1, + }, + }, + new: nil, + want: &Pan{ + Module: Module{ + current: Output{ + Mono: 1, + }, + }, + Pan: 0.5, + Mod: "mod", + In: "in", + Fade: 1, + sampleRate: sampleRate, + panFader: &fader{ + current: 0.5, + target: 0.5, + step: 0.1, + }, + }, + }, + { + name: "update all", + p: &Pan{ + Module: Module{ + current: Output{ + Mono: 1, + }, + }, + Pan: 0.5, + Mod: "mod", + In: "in", + Fade: 1, + sampleRate: sampleRate, + panFader: &fader{ + current: 0.5, + target: 0.5, + step: 0.1, + }, + }, + new: &Pan{ + Pan: -0.5, + Mod: "new-mod", + In: "new-in", + Fade: 2, + }, + want: &Pan{ + Module: Module{ + current: Output{ + Mono: 1, + }, + }, + Pan: 0.5, + Mod: "new-mod", + In: "new-in", + Fade: 2, + sampleRate: sampleRate, + panFader: &fader{ + current: 0.5, + target: -0.5, + step: -0.5 / sampleRate, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.p.Update(tt.new) + if diff := cmp.Diff(tt.want, tt.p, cmp.AllowUnexported(Module{}, Pan{}, fader{})); diff != "" { + t.Errorf("Pan.Update() diff = %s", diff) + } + }) + } +} + +func TestPan_fade(t *testing.T) { + tests := []struct { + name string + p *Pan + want *Pan + }{ + { + name: "no fade necessary", + p: &Pan{ + Pan: 0.5, + panFader: &fader{ + current: 0.5, + target: 0.5, + step: 1, + }, + }, + want: &Pan{ + Pan: 0.5, + panFader: &fader{ + current: 0.5, + target: 0.5, + }, + }, + }, + { + name: "fade", + p: &Pan{ + Pan: 0.5, + panFader: &fader{ + current: 0.5, + target: 1, + step: 0.1, + }, + }, + want: &Pan{ + Pan: 0.6, + panFader: &fader{ + current: 0.6, + target: 1, + step: 0.1, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.p.fade() + if diff := cmp.Diff(tt.want, tt.p, cmp.AllowUnexported(Module{}, Pan{}, fader{})); diff != "" { + t.Errorf("Pan.fade() diff = %s", diff) + } + }) + } +} From 8d7c2f724539a9d58aa49f456ab763a082399e2c Mon Sep 17 00:00:00 2001 From: Ilja Rotar Date: Sat, 10 Jan 2026 13:05:37 +0100 Subject: [PATCH 20/23] sampler tests --- module/pan_test.go | 8 +++-- module/sampler_test.go | 75 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 80 insertions(+), 3 deletions(-) diff --git a/module/pan_test.go b/module/pan_test.go index ec246f6..7e235b2 100644 --- a/module/pan_test.go +++ b/module/pan_test.go @@ -121,7 +121,9 @@ func TestPan_Update(t *testing.T) { p: &Pan{ Module: Module{ current: Output{ - Mono: 1, + Mono: 1, + Left: 0.5, + Right: 0.5, }, }, Pan: 0.5, @@ -144,7 +146,9 @@ func TestPan_Update(t *testing.T) { want: &Pan{ Module: Module{ current: Output{ - Mono: 1, + Mono: 1, + Left: 0.5, + Right: 0.5, }, }, Pan: 0.5, diff --git a/module/sampler_test.go b/module/sampler_test.go index 05ea705..8ae2c6e 100644 --- a/module/sampler_test.go +++ b/module/sampler_test.go @@ -1,6 +1,10 @@ package module -import "testing" +import ( + "testing" + + "github.com/google/go-cmp/cmp" +) func TestSampler_Step(t *testing.T) { tests := []struct { @@ -105,3 +109,72 @@ func TestSampler_Step(t *testing.T) { }) } } + +func TestSampler_Update(t *testing.T) { + tests := []struct { + name string + s *Sampler + new *Sampler + want *Sampler + }{ + { + name: "no update necessary", + s: &Sampler{ + Module: Module{ + current: Output{ + Mono: 1, + }, + }, + In: "in", + Trigger: "trigger", + triggerValue: 1, + }, + new: nil, + want: &Sampler{ + Module: Module{ + current: Output{ + Mono: 1, + }, + }, + In: "in", + Trigger: "trigger", + triggerValue: 1, + }, + }, + { + name: "update all", + s: &Sampler{ + Module: Module{ + current: Output{ + Mono: 1, + }, + }, + In: "in", + Trigger: "trigger", + triggerValue: 1, + }, + new: &Sampler{ + In: "new-in", + Trigger: "new-trigger", + }, + want: &Sampler{ + Module: Module{ + current: Output{ + Mono: 1, + }, + }, + In: "new-in", + Trigger: "new-trigger", + triggerValue: 1, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.s.Update(tt.new) + if diff := cmp.Diff(tt.want, tt.s, cmp.AllowUnexported(Module{}, Sampler{})); diff != "" { + t.Errorf("Sampler.Update() diff = %s", diff) + } + }) + } +} From b515a7bafdd2fd57ee09f9fc78208d9f78fa4fec Mon Sep 17 00:00:00 2001 From: Ilja Rotar Date: Sat, 10 Jan 2026 13:09:26 +0100 Subject: [PATCH 21/23] sequencer test --- module/sequencer_test.go | 93 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/module/sequencer_test.go b/module/sequencer_test.go index d537548..1be1ac4 100644 --- a/module/sequencer_test.go +++ b/module/sequencer_test.go @@ -237,3 +237,96 @@ func TestSequencer_Step(t *testing.T) { }) } } + +func TestSequencer_Update(t *testing.T) { + tests := []struct { + name string + s *Sequencer + new *Sequencer + want *Sequencer + }{ + { + name: "no update necessary", + s: &Sequencer{ + Module: Module{ + current: Output{ + Mono: 1, + }, + }, + Sequence: []string{"a_4"}, + Trigger: "trigger", + Pitch: 440, + Transpose: 1, + Randomize: true, + sequence: []float64{440}, + index: 1, + triggerValue: 1, + }, + new: nil, + want: &Sequencer{ + Module: Module{ + current: Output{ + Mono: 1, + }, + }, + Sequence: []string{"a_4"}, + Trigger: "trigger", + Pitch: 440, + Transpose: 1, + Randomize: true, + sequence: []float64{440}, + index: 1, + triggerValue: 1, + }, + }, + { + name: "update all", + s: &Sequencer{ + Module: Module{ + current: Output{ + Mono: 1, + }, + }, + Sequence: []string{"a_4"}, + Trigger: "trigger", + Pitch: 440, + Transpose: 1, + Randomize: true, + sequence: []float64{440}, + index: 1, + triggerValue: 1, + }, + new: &Sequencer{ + Sequence: []string{"a_5"}, + Trigger: "new-trigger", + Pitch: 441, + Transpose: 2, + Randomize: false, + sequence: []float64{880}, + }, + want: &Sequencer{ + Module: Module{ + current: Output{ + Mono: 1, + }, + }, + Sequence: []string{"a_5"}, + Trigger: "new-trigger", + Pitch: 441, + Transpose: 2, + Randomize: false, + sequence: []float64{880}, + index: 1, + triggerValue: 1, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.s.Update(tt.new) + if diff := cmp.Diff(tt.want, tt.s, cmp.AllowUnexported(Module{}, Sequencer{})); diff != "" { + t.Errorf("Sequencer.Update() diff = %s", diff) + } + }) + } +} From 2cf56a77553c9bc19b711abbecd03bbff64abd1f Mon Sep 17 00:00:00 2001 From: Ilja Rotar Date: Sat, 10 Jan 2026 13:16:58 +0100 Subject: [PATCH 22/23] wavetable test --- module/wavetable.go | 4 + module/wavetable_test.go | 163 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 167 insertions(+) diff --git a/module/wavetable.go b/module/wavetable.go index b677832..97562f4 100644 --- a/module/wavetable.go +++ b/module/wavetable.go @@ -84,6 +84,10 @@ func (w *Wavetable) Step(modules ModuleMap) { mod := math.Pow(2, getMono(modules[w.Mod])) w.idx += freq * mod * float64(length) / w.sampleRate + w.fade() +} + +func (w *Wavetable) fade() { if w.freqFader != nil { w.Freq = w.freqFader.fade() } diff --git a/module/wavetable_test.go b/module/wavetable_test.go index 34d2e40..791c749 100644 --- a/module/wavetable_test.go +++ b/module/wavetable_test.go @@ -98,3 +98,166 @@ func TestWavetable_Step(t *testing.T) { }) } } + +func TestWavetable_Update(t *testing.T) { + sampleRate := 44100.0 + + tests := []struct { + name string + w *Wavetable + new *Wavetable + want *Wavetable + }{ + { + name: "no update necessary", + w: &Wavetable{ + Module: Module{ + current: Output{ + Mono: 1, + }, + }, + Freq: 440, + CV: "cv", + Mod: "mod", + Signal: []float64{1, 0, -1, 0}, + Fade: 1, + sampleRate: sampleRate, + idx: 1, + freqFader: &fader{ + current: 440, + target: 440, + step: 10, + }, + }, + new: nil, + want: &Wavetable{ + Module: Module{ + current: Output{ + Mono: 1, + }, + }, + Freq: 440, + CV: "cv", + Mod: "mod", + Signal: []float64{1, 0, -1, 0}, + Fade: 1, + sampleRate: sampleRate, + idx: 1, + freqFader: &fader{ + current: 440, + target: 440, + step: 10, + }, + }, + }, + { + name: "update all", + w: &Wavetable{ + Module: Module{ + current: Output{ + Mono: 1, + }, + }, + Freq: 440, + CV: "cv", + Mod: "mod", + Signal: []float64{1, 0, -1, 0}, + Fade: 1, + sampleRate: sampleRate, + idx: 1, + freqFader: &fader{ + current: 440, + target: 440, + step: 10, + }, + }, + new: &Wavetable{ + Freq: 880, + CV: "new-cv", + Mod: "new-mod", + Signal: []float64{0, 1, 0, -1}, + Fade: 2, + }, + want: &Wavetable{ + Module: Module{ + current: Output{ + Mono: 1, + }, + }, + Freq: 440, + CV: "new-cv", + Mod: "new-mod", + Signal: []float64{0, 1, 0, -1}, + Fade: 2, + sampleRate: sampleRate, + idx: 1, + freqFader: &fader{ + current: 440, + target: 880, + step: 220 / sampleRate, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.w.Update(tt.new) + if diff := cmp.Diff(tt.want, tt.w, cmp.AllowUnexported(Module{}, Wavetable{}, fader{})); diff != "" { + t.Errorf("Wavetable.Update() diff = %s", diff) + } + }) + } +} + +func TestWavetable_fade(t *testing.T) { + tests := []struct { + name string + w *Wavetable + want *Wavetable + }{ + { + name: "no fade necessary", + w: &Wavetable{ + Freq: 440, + freqFader: &fader{ + current: 440, + target: 440, + step: 12, + }, + }, + want: &Wavetable{ + Freq: 440, + freqFader: &fader{ + current: 440, + target: 440, + }, + }, + }, + { + name: "fade", + w: &Wavetable{ + Freq: 440, + freqFader: &fader{ + current: 440, + target: 800, + step: 10, + }, + }, + want: &Wavetable{ + Freq: 450, + freqFader: &fader{ + current: 450, + target: 800, + step: 10}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.w.fade() + if diff := cmp.Diff(tt.want, tt.w, cmp.AllowUnexported(Module{}, Wavetable{}, fader{})); diff != "" { + t.Errorf("Wavetable.fade() diff = %s", diff) + } + }) + } +} From a48ea9eece025e08cdf7fc1cd0aa497fcc3d51dd Mon Sep 17 00:00:00 2001 From: Ilja Rotar Date: Sat, 10 Jan 2026 13:18:57 +0100 Subject: [PATCH 23/23] remove fade in examples --- examples/mixer.yaml | 1 - examples/noise.yaml | 1 - examples/sine-440.yaml | 1 - 3 files changed, 3 deletions(-) diff --git a/examples/mixer.yaml b/examples/mixer.yaml index 1ff9588..f97c34f 100644 --- a/examples/mixer.yaml +++ b/examples/mixer.yaml @@ -3,7 +3,6 @@ out: main mixers: main: - fade: 3 gain: 1 in: sine1: 0.25 diff --git a/examples/noise.yaml b/examples/noise.yaml index e2567a7..e9751c3 100644 --- a/examples/noise.yaml +++ b/examples/noise.yaml @@ -4,7 +4,6 @@ out: lp filters: lp: type: BandPass - fade: 1 freq: 800 width: 20 in: noise diff --git a/examples/sine-440.yaml b/examples/sine-440.yaml index 536b9c4..7188104 100644 --- a/examples/sine-440.yaml +++ b/examples/sine-440.yaml @@ -3,6 +3,5 @@ out: sine oscillators: sine: - fade: 1 type: Sine freq: 440