diff options
-rw-r--r-- | .gitignore | 2 | ||||
-rw-r--r-- | examples/src/main/java/com/jsyn/examples/ChebyshevSong.java | 25 | ||||
-rw-r--r-- | examples/src/main/java/com/jsyn/examples/InvestigateCordic.java | 100 | ||||
-rw-r--r-- | examples/src/main/java/com/jsyn/examples/MeasurePlateReverb.java | 129 | ||||
-rw-r--r-- | examples/src/main/java/com/jsyn/examples/TuneReverb.java | 116 | ||||
-rw-r--r-- | src/main/java/com/jsyn/dsp/AllPassDelay.java | 37 | ||||
-rw-r--r-- | src/main/java/com/jsyn/dsp/SimpleDelay.java | 71 | ||||
-rw-r--r-- | src/main/java/com/jsyn/engine/SynthesisEngine.java | 7 | ||||
-rw-r--r-- | src/main/java/com/jsyn/swing/PortControllerFactory.java | 7 | ||||
-rw-r--r-- | src/main/java/com/jsyn/unitgen/MultiTapDelay.java | 85 | ||||
-rw-r--r-- | src/main/java/com/jsyn/unitgen/Pan.java | 1 | ||||
-rw-r--r-- | src/main/java/com/jsyn/unitgen/PlateReverb.java | 366 | ||||
-rw-r--r-- | src/main/java/com/jsyn/unitgen/RoomReverb.java | 175 | ||||
-rw-r--r-- | src/test/java/com/jsyn/dsp/TestSimpleDelay.java | 73 |
14 files changed, 1181 insertions, 13 deletions
@@ -1,3 +1,5 @@ +*/.DS_Store +.DS_Store build/ .gradle/ diff --git a/examples/src/main/java/com/jsyn/examples/ChebyshevSong.java b/examples/src/main/java/com/jsyn/examples/ChebyshevSong.java index 2dbab88..d4b8d15 100644 --- a/examples/src/main/java/com/jsyn/examples/ChebyshevSong.java +++ b/examples/src/main/java/com/jsyn/examples/ChebyshevSong.java @@ -17,16 +17,19 @@ package com.jsyn.examples; import java.awt.BorderLayout; +import java.awt.GridLayout; import javax.swing.JApplet; +import javax.swing.JPanel; import com.jsyn.JSyn; import com.jsyn.Synthesizer; import com.jsyn.instruments.WaveShapingVoice; import com.jsyn.scope.AudioScope; import com.jsyn.swing.JAppletFrame; -import com.jsyn.unitgen.Add; import com.jsyn.unitgen.LineOut; +import com.jsyn.unitgen.RoomReverb; +import com.jsyn.unitgen.PassThrough; import com.jsyn.util.PseudoRandom; import com.jsyn.util.VoiceAllocator; import com.softsynth.math.AudioMath; @@ -40,7 +43,8 @@ import com.softsynth.shared.time.TimeStamp; public class ChebyshevSong extends JApplet implements Runnable { private Synthesizer synth; - private Add mixer; + private PassThrough mixer; // use input as a summing node + private RoomReverb reverb; private LineOut lineOut; private AudioScope scope; private volatile boolean go = false; @@ -71,18 +75,19 @@ public class ChebyshevSong extends JApplet implements Runnable { synth = JSyn.createSynthesizer(); // Use a submix so we can show it on the scope. - synth.add(mixer = new Add()); + synth.add(mixer = new PassThrough()); synth.add(lineOut = new LineOut()); - - mixer.output.connect(0, lineOut.input, 0); - mixer.output.connect(0, lineOut.input, 1); + synth.add(reverb = new RoomReverb(1.0)); + mixer.output.connect(reverb.input); + mixer.output.connect(0, lineOut.input, 0); // dry + reverb.output.connect(0, lineOut.input, 1); // wet WaveShapingVoice[] voices = new WaveShapingVoice[MAX_VOICES]; for (int i = 0; i < MAX_VOICES; i++) { WaveShapingVoice voice = new WaveShapingVoice(); synth.add(voice); voice.usePreset(0); - voice.getOutput().connect(mixer.inputA); + voice.getOutput().connect(mixer.input); voices[i] = voice; } allocator = new VoiceAllocator(voices); @@ -94,11 +99,16 @@ public class ChebyshevSong extends JApplet implements Runnable { // Use a scope to show the mixed output. scope = new AudioScope(synth); scope.addProbe(mixer.output); + scope.addProbe(reverb.output); scope.setTriggerMode(AudioScope.TriggerMode.NORMAL); scope.getView().setControlsVisible(false); add(BorderLayout.CENTER, scope.getView()); scope.start(); + JPanel southPanel = new JPanel(); + southPanel.setLayout(new GridLayout(0, 1)); + add(BorderLayout.SOUTH, southPanel); + /* Synchronize Java display. */ getParent().validate(); getToolkit().sync(); @@ -107,7 +117,6 @@ public class ChebyshevSong extends JApplet implements Runnable { Thread thread = new Thread(this); go = true; thread.start(); - } @Override diff --git a/examples/src/main/java/com/jsyn/examples/InvestigateCordic.java b/examples/src/main/java/com/jsyn/examples/InvestigateCordic.java new file mode 100644 index 0000000..a1bcfb8 --- /dev/null +++ b/examples/src/main/java/com/jsyn/examples/InvestigateCordic.java @@ -0,0 +1,100 @@ +/* + * Copyright 2023 Phil Burk, Mobileer Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.jsyn.examples; + +/** + * Experiment with Cordic oscillators. + * Implement using float and double values internally. + */ +class CordicOscillatorFloat { + private float mCosPhi; + private float x; + private float y = -1.0f; + private float mSinPhi; + + CordicOscillatorFloat() { + setFrequency(441.0, 44100.0); + } + + public void setFrequency(double frequency, double sampleRate) { + double radians = frequency * Math.PI * 2.0 / sampleRate; + mCosPhi = (float) Math.cos(radians); + mSinPhi = (float) Math.sin(radians); + } + + public double generate() { + float x2 = x * mCosPhi - y * mSinPhi; + float y2 = y * mCosPhi + x * mSinPhi; + x = Math.min(x2, 1.0f); + y = y2; + return x; + } +} + + +class CordicOscillator { + private double mCosPhi; + private double x; + private double y = -1.0; + private double mSinPhi; + + CordicOscillator() { + setFrequency(441.0, 44100.0); + } + + public void setFrequency(double frequency, double sampleRate) { + double radians = frequency * Math.PI * 2.0 / sampleRate; + mCosPhi = Math.cos(radians); + mSinPhi = Math.sin(radians); + } + + public double generate() { + double x2 = x * mCosPhi - y * mSinPhi; + double y2 = y * mCosPhi + x * mSinPhi; + x = x2; // Math.min(x2, 1.0); + y = y2; + return x; + } +} + +public class InvestigateCordic +{ + public void test() { + CordicOscillator oscillator = new CordicOscillator(); + oscillator.setFrequency(1.0, 44100.0); + for (int i = 0; i < 100; i++) { + double x = oscillator.generate(); + System.out.println("x = " + x); + } + double peak = 0.0; + for (int n = 0; n < 200; n++) { + peak = 0.0; + for (int i = 0; i < 100000000; i++) { + double x = oscillator.generate(); + if (x > peak) { + peak = x; + } + } + System.out.println(n + ": peak = " + peak); + } + } + + public static void main(String[] args) { + new InvestigateCordic().test(); + System.exit(0); + } +} diff --git a/examples/src/main/java/com/jsyn/examples/MeasurePlateReverb.java b/examples/src/main/java/com/jsyn/examples/MeasurePlateReverb.java new file mode 100644 index 0000000..db05251 --- /dev/null +++ b/examples/src/main/java/com/jsyn/examples/MeasurePlateReverb.java @@ -0,0 +1,129 @@ +/* + * Copyright 2022 Phil Burk, Mobileer Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.jsyn.examples; + +import com.jsyn.JSyn; +import com.jsyn.Synthesizer; +import com.jsyn.unitgen.EnvelopeDAHDSR; +import com.jsyn.unitgen.ImpulseOscillator; +import com.jsyn.unitgen.LineOut; +import com.jsyn.unitgen.PeakFollower; +import com.jsyn.unitgen.PinkNoise; +import com.jsyn.unitgen.PlateReverb; +import com.jsyn.unitgen.SawtoothOscillator; +import com.jsyn.unitgen.SineOscillator; +import com.jsyn.unitgen.SquareOscillator; +import com.jsyn.unitgen.UnitOscillator; +import com.jsyn.unitgen.WhiteNoise; +import com.softsynth.math.AudioMath; + +/** + * Measure the decay time of a PlateReverb tail. + */ +public class MeasurePlateReverb { + + private double measure(double size, double time, double damping) throws InterruptedException { + // Create a context for the synthesizer. + Synthesizer synth = JSyn.createSynthesizer(); + synth.setRealTime(false); + + // Add a signal source. + WhiteNoise source = new WhiteNoise(); + PlateReverb reverb = new PlateReverb(size); + PeakFollower peak = new PeakFollower(); + LineOut lineOut = new LineOut(); + + synth.add(source); + synth.add(peak); + synth.add(reverb); + synth.add(lineOut); + + source.amplitude.set(1.0); + peak.halfLife.set(0.01); + reverb.time.set(time); + reverb.damping.set(damping); + + source.output.connect(reverb.input); + reverb.output.connect(peak.input); + peak.output.connect(0, lineOut.input, 0); + reverb.output.connect(0, lineOut.input, 1); + + // Start synthesizer using default stereo output at 44100 Hz. + synth.start(); + lineOut.start(); + + // Sleep while the sound is generated in the background. + double rt60 = 0.0; + final double REFERENCE_DB = -60.0; + final double TARGET_DB = -30.0; + synth.sleepFor(1.0); + double original = peak.output.getValue(); + source.amplitude.set(0.0); + double startTime = synth.getCurrentTime(); +// System.out.printf(" time, ratio, db\n"); + double db = 1.0; + double elapsed; + int count = 0; + do { + synth.sleepUntil(startTime + (count++ * 0.1)); + double level = peak.output.getValue(); + elapsed = synth.getCurrentTime() - startTime; + double ratio = level / original; + db = AudioMath.amplitudeToDecibels(ratio); +// System.out.printf(" %3.3f, %6.4f, %6.3f\n", +// elapsed, ratio, db); + } while (db > TARGET_DB && elapsed < 30.0); + if (elapsed >= 30.0) { + System.out.println("TIMEOUT!"); + } + // Time to reach reference; + rt60 = REFERENCE_DB * elapsed / db; + // Stop everything. + synth.stop(); + + return rt60; + } + +// private double estimateRT60(double size, double decay) { +// return size * (0.52 - (4.7 * Math.log(1.0001 - (decay * decay)))); +// } + + private void test() { + double damping = 0.0005; + for (double size = 0.2; size < 3.0; size *= 1.5) { + System.out.printf("\nsize = %5.2f\n", size); + System.out.printf("time, rt60\n"); + for (double time = 0.1; time < 30.0; time *= 1.2) { + double rt60 = 0.0; + try { + rt60 = measure(size, time, damping); + } catch (InterruptedException e) { + // TODO Auto-generated catch block + e.printStackTrace(); + } + // double estimate = estimateRT60(size, decay); + System.out.printf("%5.3f, %6.4f\n", + time, rt60); + } + } + } + + public static void main(String[] args) { + new MeasurePlateReverb().test(); + System.exit(0); + } +} diff --git a/examples/src/main/java/com/jsyn/examples/TuneReverb.java b/examples/src/main/java/com/jsyn/examples/TuneReverb.java new file mode 100644 index 0000000..e38a2f7 --- /dev/null +++ b/examples/src/main/java/com/jsyn/examples/TuneReverb.java @@ -0,0 +1,116 @@ +/* + * Copyright 2023 Phil Burk + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.jsyn.examples; + +import com.jsyn.JSyn; +import com.jsyn.Synthesizer; +import com.jsyn.swing.JAppletFrame; +import com.jsyn.swing.PortControllerFactory; +import com.jsyn.unitgen.EnvelopeDAHDSR; +import com.jsyn.unitgen.LineOut; +import com.jsyn.unitgen.Pan; +import com.jsyn.unitgen.PinkNoise; +import com.jsyn.unitgen.RoomReverb; +import com.jsyn.unitgen.SawtoothOscillatorDPW; +import com.jsyn.unitgen.SquareOscillator; +import java.awt.GridLayout; +import javax.swing.JApplet; + +/** + * Play various sounds interactively through a reverb. + */ +public class TuneReverb extends JApplet { + private Synthesizer synth; + + private PinkNoise noise; + private SawtoothOscillatorDPW sawtooth; + // Use a square wave to trigger the envelope. + private SquareOscillator gatingOsc; + private EnvelopeDAHDSR dahdsr; + private Pan dryWet; + private RoomReverb reverb; + private LineOut lineOut; + + @Override + public void init() { + synth = JSyn.createSynthesizer(); + + synth.add(noise = new PinkNoise()); + synth.add(sawtooth = new SawtoothOscillatorDPW()); + synth.add(gatingOsc = new SquareOscillator()); + synth.add(dahdsr = new EnvelopeDAHDSR()); + synth.add(dryWet = new Pan()); + synth.add(reverb = new RoomReverb()); + synth.add(lineOut = new LineOut()); + + // Connect the oscillator to both channels of the output. + gatingOsc.output.connect(dahdsr.input); + gatingOsc.frequency.set(0.5); + dahdsr.attack.set(0.01); + dahdsr.decay.set(0.05); + dahdsr.sustain.set(0.00); + + noise.output.connect(dahdsr.amplitude); + sawtooth.output.connect(dahdsr.amplitude); + dahdsr.output.connect(dryWet.input); + dryWet.output.connect(1, reverb.input, 0); + dryWet.output.connect(0, lineOut.input, 0); + dryWet.output.connect(0, lineOut.input, 1); + reverb.output.connect(0, lineOut.input, 0); + reverb.output.connect(0, lineOut.input, 1); + + // Arrange the faders in a stack. + setLayout(new GridLayout(0, 1)); + + gatingOsc.frequency.setup(0.1, 0.5, 4.0); + add(PortControllerFactory.createExponentialPortSlider(sawtooth.frequency)); + add(PortControllerFactory.createExponentialPortSlider(sawtooth.amplitude)); + add(PortControllerFactory.createExponentialPortSlider(noise.amplitude)); + add(PortControllerFactory.createExponentialPortSlider(gatingOsc.frequency)); + add(PortControllerFactory.createPortSlider(dryWet.pan)); + add(PortControllerFactory.createExponentialPortSlider(reverb.preDelayMillis)); + add(PortControllerFactory.createExponentialPortSlider(reverb.multiTap)); + add(PortControllerFactory.createExponentialPortSlider(reverb.diffusion)); + add(PortControllerFactory.createExponentialPortSlider(reverb.time)); + add(PortControllerFactory.createExponentialPortSlider(reverb.damping)); + validate(); + } + + @Override + public void start() { + // Start synthesizer using default stereo output at 44100 Hz. + synth.start(); + // We only need to start the LineOut. It will pull data from the + // oscillator. + lineOut.start(); + } + + @Override + public void stop() { + synth.stop(); + } + + /* Can be run as either an application or as an applet. */ + public static void main(String[] args) { + TuneReverb applet = new TuneReverb(); + JAppletFrame frame = new JAppletFrame("Tune Reverb", applet); + frame.setSize(440, 600); + frame.setVisible(true); + frame.test(); + } + +} diff --git a/src/main/java/com/jsyn/dsp/AllPassDelay.java b/src/main/java/com/jsyn/dsp/AllPassDelay.java new file mode 100644 index 0000000..4afea19 --- /dev/null +++ b/src/main/java/com/jsyn/dsp/AllPassDelay.java @@ -0,0 +1,37 @@ +/* + * Copyright 2022 Phil Burk + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.jsyn.dsp; + +public class AllPassDelay { + private float[] mBuffer; + private int mCursor; + private float mCoefficient = 0.65f; + + public AllPassDelay(int length, float coefficient) { + mBuffer = new float[length]; + mCoefficient = coefficient; + } + + public float process(float input) { + float z = mBuffer[mCursor]; + float x = input - (z * mCoefficient); + mBuffer[mCursor] = x; + mCursor++; + if (mCursor >= mBuffer.length) mCursor = 0; + return z + (x * mCoefficient); + } +} diff --git a/src/main/java/com/jsyn/dsp/SimpleDelay.java b/src/main/java/com/jsyn/dsp/SimpleDelay.java new file mode 100644 index 0000000..b4818c1 --- /dev/null +++ b/src/main/java/com/jsyn/dsp/SimpleDelay.java @@ -0,0 +1,71 @@ +/* + * Copyright 2022 Phil Burk + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.jsyn.dsp; + +/** + * Delay line based on a circular buffer. + */ +public class SimpleDelay { + private float[] mBuffer; + private int mCursor; + + public SimpleDelay(int length) { + mBuffer = new float[length]; + } + + /** + * Read a value from the delay line. + * @param position positive delay in frames + * @return delayed value + */ + public float read(int position) { + int index = mCursor - position; + if (index < 0) { + index += mBuffer.length; + } + return mBuffer[index]; + } + + /** + * Write a new value to the head of the delay line. + * This does not advance the cursor. + * @param input sample value + */ + public void write(float input) { + mBuffer[mCursor] = input; + } + + /** + * Advance the cursor position. Wrap around in a circle. + */ + public void advance() { + mCursor++; + if (mCursor >= mBuffer.length) mCursor = 0; + } + + /** + * Add a new value and return the oldest value in the delay line. + * @param input sample value + * @return oldest value + */ + public float process(float input) { + float output = mBuffer[mCursor]; + write(input); + advance(); + return output; + } +} diff --git a/src/main/java/com/jsyn/engine/SynthesisEngine.java b/src/main/java/com/jsyn/engine/SynthesisEngine.java index 34fffbe..6d985b4 100644 --- a/src/main/java/com/jsyn/engine/SynthesisEngine.java +++ b/src/main/java/com/jsyn/engine/SynthesisEngine.java @@ -224,8 +224,11 @@ public class SynthesisEngine implements Synthesizer { setupAudioBuffers(numInputChannels, numOutputChannels); - logger.info("Pure Java JSyn from www.softsynth.com, rate = " + frameRate + ", " - + (useRealTime ? "RT" : "NON-RealTime") + ", " + JSyn.VERSION_TEXT); + if (false) { + logger.info("Pure Java JSyn from www.softsynth.com, rate = " + frameRate + + ", " + (useRealTime ? "RT" : "NON-RealTime") + + ", " + JSyn.VERSION_TEXT); + } inverseNyquist = 2.0 / frameRate; diff --git a/src/main/java/com/jsyn/swing/PortControllerFactory.java b/src/main/java/com/jsyn/swing/PortControllerFactory.java index a73d047..0f98ea6 100644 --- a/src/main/java/com/jsyn/swing/PortControllerFactory.java +++ b/src/main/java/com/jsyn/swing/PortControllerFactory.java @@ -4,9 +4,9 @@ * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at - * + * * http://www.apache.org/licenses/LICENSE-2.0 - * + * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -19,11 +19,12 @@ package com.jsyn.swing; import javax.swing.event.ChangeEvent; import javax.swing.event.ChangeListener; +import com.jsyn.ports.SettablePort; import com.jsyn.ports.UnitInputPort; /** * Factory class for making various controllers for JSyn ports. - * + * * @author Phil Burk (C) 2010 Mobileer Inc */ public class PortControllerFactory { diff --git a/src/main/java/com/jsyn/unitgen/MultiTapDelay.java b/src/main/java/com/jsyn/unitgen/MultiTapDelay.java new file mode 100644 index 0000000..17db94a --- /dev/null +++ b/src/main/java/com/jsyn/unitgen/MultiTapDelay.java @@ -0,0 +1,85 @@ +/* + * Copyright 2023 Phil Burk + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.jsyn.unitgen; + +import com.jsyn.dsp.SimpleDelay; +import com.jsyn.ports.UnitInputPort; + +/** + * Delay with multiple read positions and associated gains. + */ +public class MultiTapDelay extends UnitFilter { + + /** Pre-delay time in milliseconds. */ + public UnitInputPort preDelayMillis; + private final int mMaxPreDelayFrames; + private SimpleDelay mPreDelay; + private SimpleDelay mDelay; + private final int[] mPositions; + private final float[] mGains; + + private int mPreDelayFrames = 0; + + /** + * Construct a delay line with specified taps. + * The allocated size of the delay line will be the maximum position plus the maxPreDelayFrames. + * @param positions delay index, eg. 172 for Z(n-172) + * @param gains multiplier for the corresponding position + * @param maxPreDelayFrames extra allocated frames for pre-delay before the taps + */ + public MultiTapDelay(final int[] positions, + final float[] gains, + final int maxPreDelayFrames) { + mPositions = positions; + mGains = gains; + + preDelayMillis = new UnitInputPort("PreDelayMillis"); + double maxMillis = maxPreDelayFrames * 1000.0 / 44100; // TODO handle unknown frame rate better + preDelayMillis.setup(0.0, Math.min(10.0, maxMillis), maxMillis); + addPort(preDelayMillis); + mMaxPreDelayFrames = Math.max(1, maxPreDelayFrames); + mPreDelay = new SimpleDelay(maxPreDelayFrames); + + int maxPosition = 0; + for (int position : positions) { + maxPosition = Math.max(maxPosition, position); + } + mDelay = new SimpleDelay(maxPosition); + } + + @Override + public void generate(int start, int limit) { + double[] inputs = input.getValues(); + double[] outputs = output.getValues(); + + double preDelayMS = preDelayMillis.getValues()[0]; + int preDelayFrames = (int)(preDelayMS * 0.001 * getFrameRate()); + preDelayFrames = Math.max(1, Math.min(mMaxPreDelayFrames, preDelayFrames)); + + for (int i = start; i < limit; i++) { + mPreDelay.write((float) inputs[i]); + mDelay.write(mPreDelay.read(preDelayFrames)); + mPreDelay.advance(); + double sum = 0.0; + for (int tap = 0; tap < mPositions.length; tap++) { + sum += mDelay.read(mPositions[tap]) * mGains[tap]; + } + mDelay.advance(); + outputs[i] = sum; // mix taps + } + } +} diff --git a/src/main/java/com/jsyn/unitgen/Pan.java b/src/main/java/com/jsyn/unitgen/Pan.java index bc90984..77b2694 100644 --- a/src/main/java/com/jsyn/unitgen/Pan.java +++ b/src/main/java/com/jsyn/unitgen/Pan.java @@ -41,6 +41,7 @@ public class Pan extends UnitGenerator { public Pan() { addPort(input = new UnitInputPort("Input")); addPort(pan = new UnitInputPort("Pan")); + pan.setup(-1.0, 0.0, 1.0); addPort(output = new UnitOutputPort(2, "Output")); } diff --git a/src/main/java/com/jsyn/unitgen/PlateReverb.java b/src/main/java/com/jsyn/unitgen/PlateReverb.java new file mode 100644 index 0000000..88eef33 --- /dev/null +++ b/src/main/java/com/jsyn/unitgen/PlateReverb.java @@ -0,0 +1,366 @@ +/* + * Copyright 2022 Phil Burk + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.jsyn.unitgen; + +import com.jsyn.dsp.AllPassDelay; +import com.jsyn.dsp.SimpleDelay; +import com.jsyn.ports.UnitInputPort; +import com.jsyn.ports.UnitOutputPort; +import com.jsyn.util.PseudoRandom; + +/** + * Simple reverberation effect based on a "figure eight" + * network of all-pass filters and delays. + * + * This reverb does not have a pre-delay or early reflections. + * It can be used as the "tail" of a more complex reverb that + * adds those functions. + * + * The algorithm is based on + * "Effect Design Part 1: Reverberator and Other Filters" + * by Jon Dattorro, CCRMA, Stanford University 1996 + * + * @see InterpolatingDelay + */ + +public class PlateReverb extends UnitGenerator { + + /** + * Mono input. + */ + public UnitInputPort input; + + /** + * Approximate time in seconds to decay by -60 dB. + */ + public UnitInputPort time; + + /** + * Damping factor for the feedback filters. + * Must be between 0.0 and 1.0. Default is 0.5. + */ + public UnitInputPort damping; + + /** + * Stereo output. + */ + public UnitOutputPort output; + + private static final double MAX_DECAY = 0.98; + // These default values are based on table-1 of the paper by Jon Dattorro. + private static final float DECAY_DIFFUSION_1 = 0.70f; + private static final float DECAY_DIFFUSION_2 = 0.50f; + private static final float INPUT_DIFFUSION_1 = 0.75f; + private static final float INPUT_DIFFUSION_2 = 0.625f; + private static final float DAMPING = 0.5f; // Must match default comment above for damping port. + private static final float BANDWIDTH = 0.99995f; + + private static class FastSineOscillator { + private float mPhaseIncrement = 0.0001f; + private float mPhaseDelta = mPhaseIncrement; + private float mPhase; // ranges from -PI/2 to PI/2 + private static final float PHASE_LIMIT = (float) Math.PI * 0.5f; + + void setFrequency(float frequency, float sampleRate) { + mPhaseIncrement = (float) (frequency * Math.PI / sampleRate); + } + + float generate() { + // Generate a triangle wave + mPhase += mPhaseDelta; + if (mPhase > PHASE_LIMIT) { + mPhase = PHASE_LIMIT - (mPhase - PHASE_LIMIT); + mPhaseDelta = -mPhaseIncrement; // reverse direction + } else if (mPhase < -PHASE_LIMIT) { + mPhase = -PHASE_LIMIT + (-PHASE_LIMIT - mPhase); + mPhaseDelta = mPhaseIncrement; // reverse direction + } + + // Factorial constants so code is easier to read. + final float IF3 = 1.0f / (2 * 3); + final float IF5 = IF3 / (4 * 5); + final float IF7 = IF5 / (6 * 7); + final float IF9 = IF7 / (8 * 9); + final float IF11 = IF9 / (10 * 11); + + float x = mPhase; + float x2 = (x * x); + /* Taylor expansion factored into multiply-adds */ + // TODO use fewer factors cuz just modulation + return x + * (x2 * (x2 * (x2 * (x2 * ((x2 * (-IF11)) + IF9) - IF7) + IF5) - IF3) + 1); + } + } + + private static class RandomModulator { + private PseudoRandom randomNum = new PseudoRandom();; + protected float prevNoise, currNoise; + private float mPhase; + private float mPhaseIncrement; + + void setFrequency(float frequency, float sampleRate) { + mPhaseIncrement = frequency / sampleRate; + } + + // Generate ramps between random points between -1.0 and +1.0. + public float generate() { + mPhase += mPhaseIncrement; + + // calculate new random value whenever phase passes 1.0 + if (mPhase > 1.0) { + prevNoise = currNoise; + currNoise = (float) randomNum.nextRandomDouble(); + // reset phase for interpolation + mPhase -= 1.0; + } + + // interpolate current + return prevNoise + (mPhase * (currNoise - prevNoise)); + } + } + + /** + * Allpass delay modulated by a random ramp. + */ + private static class VariableAllPassDelay { + RandomModulator mModulator = new RandomModulator(); + private float[] mBuffer; + private int mLength; + private int mCursor; + private int mModulationDepth; + private float mCoefficient = 0.65f; + + VariableAllPassDelay(int length, float coefficient) { + mLength = length; + mBuffer = new float[2 * length]; + mCoefficient = coefficient; + setModulationDepth(40); + } + + void setModulationDepth(int depthInFrames) { + mModulationDepth = Math.min(depthInFrames, mLength / 3); + } + + void setFrequency(float frequency, float sampleRate) { + mModulator.setFrequency(frequency, sampleRate); + } + + private float process(float input) { + int readCursor = mCursor - mLength; + readCursor += (int)(mModulator.generate() * mModulationDepth); + if (readCursor < 0) readCursor += mBuffer.length; + + float z = mBuffer[readCursor]; + + float x = input - (z * mCoefficient ); + mBuffer[mCursor] = x; + mCursor++; + if (mCursor >= mBuffer.length) mCursor = 0; + return z + (x * mCoefficient); + } + } + + // y = x*c + y*(1-c) + private static class OnePoleLowPassFilter { + private float mDelay; + private float mCoefficient; + + OnePoleLowPassFilter(float coefficient) { + mCoefficient = coefficient; + } + + private float process(float input) { + float output = (input * mCoefficient) + + (mDelay * (1.0f - mCoefficient)); + mDelay = output; + return output; + } + + public void setCoefficient(float coefficient) { + mCoefficient = coefficient; + } + } + + // One side of the figure eight. + private class ReverbSide { + VariableAllPassDelay variableDelay; + OnePoleLowPassFilter mLowPass = new OnePoleLowPassFilter(1.0f - DAMPING); + SimpleDelay mDelay1; + AllPassDelay mAllPassDelay; + SimpleDelay mDelay2; + private float outputScaler = 0.6f; + private float mOutput; + + ReverbSide(int d1, int d2, int d3, int d4) { + // This all pass reverses the signs. + variableDelay = new VariableAllPassDelay(d1, 0.0f - DECAY_DIFFUSION_1); + mDelay1 = new SimpleDelay(d2); + mAllPassDelay = new AllPassDelay(d3, DECAY_DIFFUSION_2); + mDelay2 = new SimpleDelay(d4); + } + + public void setFrequency(float frequency, float sampleRate) { + variableDelay.setFrequency(frequency, sampleRate); + } + + private float process(float input) { + float temp = variableDelay.process(input); + mOutput = temp; + temp = mDelay1.process(temp); + mOutput -= temp; + temp = mLowPass.process(temp); + temp *= mDecay; + temp = mAllPassDelay.process(temp); + mOutput += temp; + temp = mDelay2.process(temp); + temp *= mDecay; + mOutput -= temp; + return temp; + } + + private float getOutput() { + return mOutput * outputScaler; + } + + public void setDamping(float damping) { + mLowPass.setCoefficient(1.0f - damping); + } + } + + private float mDecay; + private float mLeftFeedback; + private float mRightFeedback; + private double mSize = 1.0; + private double mPreviousTime = -1.0; + + private OnePoleLowPassFilter mBandwidthLowPass = new OnePoleLowPassFilter(BANDWIDTH); + private AllPassDelay mDiffusion1 = new AllPassDelay(142, INPUT_DIFFUSION_1); + private AllPassDelay mDiffusion2 = new AllPassDelay(107, INPUT_DIFFUSION_1); + private AllPassDelay mDiffusion3 = new AllPassDelay(379, INPUT_DIFFUSION_2); + private AllPassDelay mDiffusion4 = new AllPassDelay(277, INPUT_DIFFUSION_2); + private ReverbSide mLeftSide; + private ReverbSide mRightSide; + + + /** + * Create a PlateReverb with a default size of 1.0. + */ + public PlateReverb() { + this(1.0); + } + + /** + * This reverb uses multiple delay lines. The size parameter + * scales the allocated size. A value of 1.0 is the default. + * At low values the reverb will sound more metallic, like a comb filter. + * At larger values it will sound more echoey. + * + * The size value will be clipped between 0.05 and 5.0. + * + * @param size adjust internal delay sizes + */ + public PlateReverb(double size) { + + addPort(input = new UnitInputPort("Input")); + + size = Math.max(0.05, Math.min(5.0, size)); + mSize = size; + addPort(time = new UnitInputPort("Time")); + time.setup(0.01, 2.0, 30.0); + addPort(damping = new UnitInputPort("Damping")); + damping.setup(0.0001, DAMPING, 1.0); + + addPort(output = new UnitOutputPort(2,"Output")); + + // delay line sizes + // These are from the original paper. + // int[] zs = {142, 107, 379, 277, // diffusion + // 672, 4453, 1800, 3720, // left + // 908, 4217, 2656, 3163}; // right + // These are aligned to nearby primes. + int[] zs = {149, 107, 379, 277, // diffusion + 677, 4453, 1801, 3727, // left + 911, 4217, 2657, 3169}; // right + + mDiffusion1 = new AllPassDelay((int)(zs[0] * size), INPUT_DIFFUSION_1); + mDiffusion2 = new AllPassDelay((int)(zs[1] * size), INPUT_DIFFUSION_1); + mDiffusion3 = new AllPassDelay((int)(zs[2] * size), INPUT_DIFFUSION_2); + mDiffusion4 = new AllPassDelay((int)(zs[3] * size), INPUT_DIFFUSION_2); + mLeftSide = new ReverbSide((int)(zs[4] * size), (int)(zs[5] * size), + (int)(zs[6] * size), (int)(zs[7] * size)); + mRightSide = new ReverbSide((int)(zs[8] * size), (int)(zs[9] * size), + (int)(zs[10] * size), (int)(zs[11] * size)); + mLeftSide.setFrequency(0.7f, 44100.0f); // TODO use actual sample rate + mRightSide.setFrequency(1.2f, 44100.0f); // TODO use actual sample rate + } + + // Unfortunately, Java does not have a simple duple support. + // So we return void and then get teh two values from the left and + // right sides. + private void process(float x) { + x = mBandwidthLowPass.process(x); + x = mDiffusion1.process(x); + x = mDiffusion2.process(x); + x = mDiffusion3.process(x); + x = mDiffusion4.process(x); + // left side of the figure eight uses right side feedback + float leftSum = x + mRightFeedback; + mLeftFeedback = mLeftSide.process(leftSum); + // right side of the figure eight uses left side feedback + float rightSum = x + mLeftFeedback; + mRightFeedback = mRightSide.process(rightSum); + } + + + // This equation was derived from measuring the actual RT60 as a function + // of size and decay. + // time = size * (0.52 - (4.7 * Math.log(1.0001 - (decay * decay)))); + // time/size = 0.52 - (4.7 * Math.log(1.0001 - (decay * decay))) + // time/size - 0.52 = -4.7 * Math.log(1.0001 - (decay * decay)) + // (0.52 - (time/size))/ 4.7 = Math.log(1.0001 - (decay * decay)) + // Math.exp((0.52 - (time/size))/ 4.7) = 1.0001 - (decay * decay) + // 1.001 - Math.exp((0.52 - (time/size))/ 4.7) = decay * decay + // decay = Math.sqrt(1.001 - Math.exp((0.52 - (time/size))/ 4.7)) + private double convertTimeToDecay(double size, double time) { + double exponent = (0.52 - (time / size))/ 4.7; + double square = 1.001 - Math.exp(exponent); // TODO optimize + double decay = Math.sqrt(Math.max(0.0, square)); // avoid sqrt(negative) + return Math.min(MAX_DECAY, decay); + } + + @Override + public void generate(int start, int limit) { + double[] inputs = input.getValues(); + double[] leftOutputs = output.getValues(0); + double[] rightOutputs = output.getValues(1); + + double timeValue = (float) time.getValues()[0]; + if (timeValue != mPreviousTime) { + mDecay = (float) convertTimeToDecay(mSize, timeValue); + mPreviousTime = timeValue; + } + float dampingValue = (float) damping.getValues()[0]; + mLeftSide.setDamping(dampingValue); + mRightSide.setDamping(dampingValue); + for (int i = start; i < limit; i++) { + process((float) inputs[i]); + leftOutputs[i] = mLeftSide.getOutput(); + rightOutputs[i] = mRightSide.getOutput(); + } + } + +} diff --git a/src/main/java/com/jsyn/unitgen/RoomReverb.java b/src/main/java/com/jsyn/unitgen/RoomReverb.java new file mode 100644 index 0000000..eec1a5e --- /dev/null +++ b/src/main/java/com/jsyn/unitgen/RoomReverb.java @@ -0,0 +1,175 @@ +/* + * Copyright 2022 Phil Burk + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.jsyn.unitgen; + +import com.jsyn.ports.UnitInputPort; +import com.jsyn.ports.UnitOutputPort; + +/** + * Simulate reverberation in a room using a MultiTapDelay to model the early reflections + * and a PlateReverb to provide diffusion. + * + * @author (C) 2022 Phil Burk, Mobileer Inc + * @see MultiTapDelay + * @see PlateReverb + */ +public class RoomReverb extends Circuit { + private static final double SIZE_SCALER_MIN = 0.05; + private static final double SIZE_SCALER_MAX = 5.0; + private static final int[] kPositions = { + 10, 197, 401, + 521, 733, 1117, + 1481, 2731, 4177, + 6073, 7927, 9463}; + // Gains based on attenuation in air after a pre-delay. + // See spreadsheet MiscSynthCalculations + private static final float[] kGains = { + 0.1840f, -0.1543f, -0.1311f, + 0.1205f, -0.1054f, -0.0859f, + -0.0731f, -0.0484f, 0.0347f, + 0.0254f, 0.0201f, -0.0171f}; + + /** + * Mono input. + */ + public UnitInputPort input; + + /** Pre-delay time in milliseconds. */ + public UnitInputPort preDelayMillis; + + /** + * Approximate time in seconds to decay by -60 dB. + */ + public UnitInputPort time; + + /** + * Damping factor for the feedback filters. + * Must be <= 1.0. Default is 0.5. + */ + public UnitInputPort damping; + + /** + * Amount of multi-tap delay in the output mix. + * Must be between 0.0 and 1.0. + */ + public UnitInputPort multiTap; + + /** + * Amount of diffusion in the output mix. + * Must be between 0.0 and 1.0. + */ + public UnitInputPort diffusion; + + /** + * Stereo output. + */ + public UnitOutputPort output; + + private final PlateReverb mPlateReverb; + private final MultiTapDelay mMultiTapDelay; + private final RoomReverbMixer mRoomReverbMixer; + + /** + * Construct a RoomReverb with a default size of 1.0. + */ + public RoomReverb() { + this(1.0); + } + + /** + * The size parameter scales the allocated size. + * A value of 1.0 is the default. + * At low values the reverb will sound more metallic, like a comb filter. + * At larger values it will have longer echos. + * + * The size value will be clipped between 0.05 and 5.0. + * + * @param size adjust internal delay sizes + */ + public RoomReverb(double size) { + size = Math.max(SIZE_SCALER_MIN, Math.min(SIZE_SCALER_MAX, size)); + + int[] positions = new int[kPositions.length]; + for (int tap = 0; tap < kPositions.length; tap++) { + positions[tap] = (int) (kPositions[tap] * size); + } + add(mMultiTapDelay = new MultiTapDelay(positions, kGains, + (int)(4000 * size) /* preDelayFrames */)); // roughly 80 msec max + add(mPlateReverb = new PlateReverb(1.0)); + add(mRoomReverbMixer = new RoomReverbMixer()); + + mMultiTapDelay.output.connect(mPlateReverb.input); + mMultiTapDelay.output.connect(mRoomReverbMixer.multiTapInput); + mPlateReverb.output.connect(0, mRoomReverbMixer.diffusionInput, 0); + mPlateReverb.output.connect(1, mRoomReverbMixer.diffusionInput, 1); + + // Assign ports + input = mMultiTapDelay.input; + addPort(input); + preDelayMillis = mMultiTapDelay.preDelayMillis; + addPort(preDelayMillis); + time = mPlateReverb.time; + addPort(time); + damping = mPlateReverb.damping; + addPort(damping); + multiTap = mRoomReverbMixer.multiTapGain; + addPort(multiTap); + diffusion = mRoomReverbMixer.diffusionGain; + addPort(diffusion); + output = mRoomReverbMixer.output; + addPort(output); + } + + // Custom mixer for room reverb. + // This is faster than multiple small unit generators. + static class RoomReverbMixer extends UnitGenerator { + public UnitInputPort multiTapInput; + public UnitInputPort diffusionInput; + + public UnitInputPort multiTapGain; + public UnitInputPort diffusionGain; + public UnitOutputPort output; + + /* Define Unit Ports used by connect() and set(). */ + public RoomReverbMixer() { + addPort(multiTapInput = new UnitInputPort("MultiTapInput")); + addPort(diffusionInput = new UnitInputPort(2,"DiffusionInput")); + addPort(multiTapGain = new UnitInputPort("MultiTap")); + addPort(diffusionGain = new UnitInputPort(2,"Diffusion")); + multiTapGain.setup(0.0, 1.0, 1.0); + diffusionGain.setup(0.0, 1.0, 1.0); + addPort(output = new UnitOutputPort(2,"Output")); + } + + @Override + public void generate(int start, int limit) { + double[] multiTapInputs = multiTapInput.getValues(); + double[] diffusionInputs0 = diffusionInput.getValues(0); + double[] diffusionInputs1 = diffusionInput.getValues(1); + double multiTapGainValue = multiTapGain.getValues()[start]; + double diffusionGainValue = diffusionGain.getValues()[start]; + double[] outputs0 = output.getValues(0); + double[] outputs1 = output.getValues(1); + + for (int i = start; i < limit; i++) { + double multiTapScaled = multiTapInputs[i] * multiTapGainValue; + outputs0[i] = multiTapScaled + (diffusionInputs0[i] * diffusionGainValue); + outputs1[i] = multiTapScaled + (diffusionInputs1[i] * diffusionGainValue); + } + } + } +} diff --git a/src/test/java/com/jsyn/dsp/TestSimpleDelay.java b/src/test/java/com/jsyn/dsp/TestSimpleDelay.java new file mode 100644 index 0000000..400b6d7 --- /dev/null +++ b/src/test/java/com/jsyn/dsp/TestSimpleDelay.java @@ -0,0 +1,73 @@ +/* + * Copyright 2009 Phil Burk, Mobileer Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.jsyn.dsp; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.jsyn.engine.SynthesisEngine; +import com.jsyn.unitgen.Add; +import com.jsyn.unitgen.Compare; +import com.jsyn.unitgen.Divide; +import com.jsyn.unitgen.Maximum; +import com.jsyn.unitgen.Minimum; +import com.jsyn.unitgen.Multiply; +import com.jsyn.unitgen.MultiplyAdd; +import com.jsyn.unitgen.PitchToFrequency; +import com.jsyn.unitgen.PowerOfTwo; +import com.jsyn.unitgen.Subtract; +import com.jsyn.unitgen.UnitBinaryOperator; +import com.softsynth.math.AudioMath; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * @author Phil Burk, (C) 2009 Mobileer Inc + */ +public class TestSimpleDelay { + + @Test + public void testProcess() { + SimpleDelay delay = new SimpleDelay(3); + assertEquals(0.0f, delay.process(0.0f), 0.00001, "Start with zero"); + assertEquals(0.0f, delay.process(0.7f), 0.00001, "Add an impulse"); + assertEquals(0.0f, delay.process(0.0f), 0.00001, "Waiting 0"); + assertEquals(0.0f, delay.process(0.0f), 0.00001, "Waiting 1"); + assertEquals(0.7f, delay.process(0.0f), 0.00001, "Got it."); + } + + @Test + public void testAddRead() { + SimpleDelay delay = new SimpleDelay(3); + assertEquals(0.0f, delay.read(0), 0.00001, "read[0]"); + assertEquals(0.0f, delay.read(1), 0.00001, "read[1]"); + assertEquals(0.0f, delay.read(2), 0.00001, "read[2]"); + delay.write(1.23f); + assertEquals(1.23f, delay.read(0), 0.00001, "w0 read[0]"); + assertEquals(0.0f, delay.read(1), 0.00001, "w0 read[1]"); + assertEquals(0.0f, delay.read(2), 0.00001, "w0 read[2]"); + delay.advance(); + delay.write(0.0f); + assertEquals(0.0f, delay.read(0), 0.00001, "w1 read[0]"); + assertEquals(1.23f, delay.read(1), 0.00001, "w1 read[1]"); + assertEquals(0.0f, delay.read(2), 0.00001, "w1 read[2]"); + delay.advance(); + delay.write(0.567f); + assertEquals(0.567f, delay.read(0), 0.00001, "w1 read[0]"); + assertEquals(0.0f, delay.read(1), 0.00001, "w1 read[1]"); + assertEquals(1.23f, delay.read(2), 0.00001, "w1 read[2]"); + } +} |