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/README.md b/README.md index cad3299..8530b6d 100644 --- a/README.md +++ b/README.md @@ -25,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 @@ -89,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 @@ -113,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 @@ -130,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 @@ -149,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 @@ -175,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 @@ -184,12 +209,16 @@ 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 + # 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 @@ -241,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 diff --git a/module/envelope.go b/module/envelope.go index f09c8ae..b01afdc 100644 --- a/module/envelope.go +++ b/module/envelope.go @@ -7,36 +7,69 @@ 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"` + 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) { @@ -44,12 +77,25 @@ 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 + + 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() } func (e *Envelope) Step(t float64, modules ModuleMap) { @@ -73,6 +119,7 @@ func (e *Envelope) Step(t float64, modules ModuleMap) { } e.gateValue = gateValue + e.fade() } func (e *Envelope) getValue(t float64) float64 { @@ -105,6 +152,42 @@ func (e *Envelope) decay(t float64) float64 { return linear(start, end, e.Peak, e.Level, t) } +func (e *Envelope) initializeFaders() { + 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.Fade, e.sampleRate) + } + if e.levelFader != nil { + e.levelFader.initialize(e.Fade, e.sampleRate) + } +} + +func (e *Envelope) 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) release(t float64) float64 { start := e.releasedAt end := start + e.Release diff --git a/module/envelope_test.go b/module/envelope_test.go index 692a789..01cd02e 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, + }, + decayFader: &fader{ + current: 1, + target: 1, + step: 0.5, + }, + releaseFader: &fader{ + current: 1, + target: 1, + step: 0.5, + }, + peakFader: &fader{ + current: 1, + target: 1, + step: 0.5, + }, + levelFader: &fader{ + current: 1, + target: 1, + step: 0.5, + }, + }, + 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 new file mode 100644 index 0000000..3b36eb5 --- /dev/null +++ b/module/fader.go @@ -0,0 +1,33 @@ +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 + 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 { + f.step = 0 + return f.current + } + + f.current += f.step + if (f.current > f.target) == (f.step > 0) { + f.current = f.target + } + + return f.current +} diff --git a/module/fader_test.go b/module/fader_test.go new file mode 100644 index 0000000..10ad599 --- /dev/null +++ b/module/fader_test.go @@ -0,0 +1,133 @@ +package module + +import ( + "testing" +) + +func Test_fader_initialize(t *testing.T) { + tests := []struct { + name string + duration float64 + sampleRate float64 + f *fader + wantStep float64 + }{ + { + 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) + } + }) + } +} + +func Test_fader_fade(t *testing.T) { + tests := []struct { + name string + f *fader + want float64 + }{ + { + 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) { + if got := tt.f.fade(); got != tt.want { + t.Errorf("fader.fade() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/module/filter.go b/module/filter.go index 7bba8fd..84ebce1 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 @@ -35,7 +40,7 @@ const ( filterTypeBandPass filterType = "BandPass" gain = -50 - slope = 0.99 // how is this related to width? + slope = 0.99 ) var ( @@ -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,14 @@ func (f *Filter) Update(new *Filter) { f.b0 = new.b0 f.b1 = new.b1 f.b2 = new.b2 + + if f.freqFader != nil { + f.freqFader.target = new.Freq + } + if f.widthFader != nil { + f.widthFader.target = new.Width + } + f.initializeFaders() } func (f *Filter) Step(modules ModuleMap) { @@ -101,6 +126,8 @@ func (f *Filter) Step(modules ModuleMap) { Left: y / 2, Right: y / 2, } + + f.fade() } func (f *Filter) tap(x, freq float64) float64 { @@ -182,6 +209,24 @@ func (f *Filter) calculateBandPassCoeffs(freq, width float64) { f.a2 = 1 - alpha } +func (f *Filter) initializeFaders() { + if f.freqFader != nil { + f.freqFader.initialize(f.Fade, f.sampleRate) + } + if f.widthFader != nil { + f.widthFader.initialize(f.Fade, f.sampleRate) + } +} + +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) + } + }) + } +} diff --git a/module/gate.go b/module/gate.go index af19cb9..630432c 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, 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,13 @@ 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() + } } func samplesPerBeat(sampleRate, bpm float64) float64 { 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) + } + }) + } +} diff --git a/module/mixer.go b/module/mixer.go index 905806c..530b8c2 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,76 @@ func (m *Mixer) Step(modules ModuleMap) { Left: left, Right: right, } + + m.fade() +} + +func (m *Mixer) fade() { + 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 + } + + 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] + + 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() } 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) + } + }) + } +} 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..0741665 100644 --- a/module/oscillator.go +++ b/module/oscillator.go @@ -10,14 +10,19 @@ 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"` + signal SignalFunc sampleRate float64 arg float64 + + freqFader *fader + phaseFader *fader } OscillatorMap map[string]*Oscillator @@ -47,6 +52,17 @@ 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.phaseFader = &fader{ + current: o.Phase, + target: o.Phase, + } + o.initializeFaders() signal, err := newSignalFunc(o.Type) if err != nil { @@ -63,11 +79,18 @@ 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 + + if o.freqFader != nil { + o.freqFader.target = new.Freq + } + if o.phaseFader != nil { + o.phaseFader.target = new.Phase + } + o.initializeFaders() } func (o *Oscillator) Step(modules ModuleMap) { @@ -88,4 +111,23 @@ 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() + } + if o.phaseFader != nil { + o.Phase = o.phaseFader.fade() + } +} + +func (o *Oscillator) initializeFaders() { + if o.freqFader != nil { + o.freqFader.initialize(o.Fade, o.sampleRate) + } + if o.phaseFader != nil { + o.phaseFader.initialize(o.Fade, o.sampleRate) + } } 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) + } + }) + } +} diff --git a/module/pan.go b/module/pan.go index 5adc88d..4fdcb92 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,12 @@ func (p *Pan) Step(modules ModuleMap) { Right: in * percent, 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..7e235b2 100644 --- a/module/pan_test.go +++ b/module/pan_test.go @@ -68,3 +68,162 @@ 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, + Left: 0.5, + Right: 0.5, + }, + }, + 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, + Left: 0.5, + Right: 0.5, + }, + }, + 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) + } + }) + } +} 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) + } + }) + } +} 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) + } + }) + } +} diff --git a/module/wavetable.go b/module/wavetable.go index 2a27da2..97562f4 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,12 @@ 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) + } + }) + } +} diff --git a/synth/synth.go b/synth/synth.go index fcb1171..7887cd0 100644 --- a/synth/synth.go +++ b/synth/synth.go @@ -76,9 +76,9 @@ func (s *Synth) Initialize(sampleRate float64) error { return err } - s.Envelopes.Initialize() + s.Envelopes.Initialize(sampleRate) s.Gates.Initialze(sampleRate) - s.Pans.Initialize() + s.Pans.Initialize(sampleRate) s.Wavetables.Initialize(sampleRate) return nil @@ -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() { 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},