diff --git a/documentation/CHANGELOG.md b/documentation/CHANGELOG.md index eea497c3..7b7901ed 100644 --- a/documentation/CHANGELOG.md +++ b/documentation/CHANGELOG.md @@ -15,6 +15,7 @@ * New: Added support for envelope time keyboard- and velocity-scaling, which scales the envelope times by the played key and velocity (as opposed to the already supported slopes, which describe the curvature of a segment): Akai S1000, Ensoniq EPS/ASR, Ensoniq Mirage, Logic EXS24, Reason NN-XT, Roland S-7xx, SoundFont 2, Yamaha YSFC. * New: Added support for per-instrument voice settings (polyphony and monophonic legato): Akai S1000, DecentSampler, Disting EX, Ensoniq Mirage, Logic EXS24, Reason NN-XT, Roland S-7xx, SFZ, Synthstrom Deluge, TAL Sampler. * New: Added support for group volume, panning and tuning offsets: Kontakt, DecentSampler, Logic EXS24, Synclavier, TX16Wx, Waldorf Quantum/Iridium. + * New: Added support for a pitch low frequency oscillator (vibrato) with its rate, depth and delay: DecentSampler, DLS, SFZ, SoundFont 2. Previously any vibrato was dropped on conversion. * New: Source folders and files are now processed in a stable alphabetical order instead of the file-system enumeration order, so consecutive runs behave identically (and e.g. the QPAT import numbers are assigned in a predictable order). * New: Improved logging if WAV file could not be written. * Fixed: Two filters which differed only in their cutoff envelope were treated as equal, so zones which are not identical could be combined into one; a filter with a cutoff envelope but without a cutoff velocity modulation could additionally throw an exception. diff --git a/documentation/README-FORMATS.md b/documentation/README-FORMATS.md index f177a1bf..717147d2 100644 --- a/documentation/README-FORMATS.md +++ b/documentation/README-FORMATS.md @@ -235,6 +235,8 @@ A dspreset file contains a single preset and the description of the multi-sample There are no metadata fields (category, creator, etc.) specified in the format. Therefore, information is stored and retrieved from Broadcast Audio Extension chunks in the WAV files. If no such chunks are present an [automatic detection](#automatic-metadata-detection) is applied. +A pitch LFO (vibrato) is converted as an `` bound to the group tuning, carrying its rate (in Hertz), depth and delay. The oscillator's fade-in has no equivalent and is dropped, and the waveform is mapped to the nearest of sine, square and saw. + ### Source Options * Create one multi-sample per group: Creates a separate multi-sample for each group instead of one multi-sample which contains all groups. Intended for presets which contain several alternative kits or articulations as groups and switch between them via their user interface (only one group is enabled at a time). Disabled groups are converted as well when this option is enabled; when it is off, only the enabled groups are converted. @@ -275,6 +277,8 @@ Both the program (.zbp) as well as the bank (.zbb) are stored as monoliths (zipp The DLS format (*.dls) is a standardized file format developed for storing and distributing collections of digital musical instrument sounds, enabling their use in software synthesizers and hardware devices compatible with the MIDI protocol. It encapsulates audio samples, instrument definitions, articulations, and performance parameters into a single file. Developed in the 1990s initially by the Interactive Audio Special Interest Group (IASIG) and later standardized by the MIDI Manufacturers Association (MMA), with the first formal specification released in 1999. There is no write support. +The amplitude and pitch envelopes, the sample loops and a pitch LFO (vibrato) are read. The vibrato's depth, its frequency (converted from absolute pitch cents to Hertz) and its start delay are carried over to the pitch LFO. Only a connection which is not modulated by a controller is read as the vibrato; the format normally contains a second one controlled by the modulation wheel, which is the amount the wheel can dial in and would sound permanently if it were converted. + ## Elektron Tonverk The Elektron Tonverk is a dedicated hardware sampler that marks an important milestone for Elektron as its first instrument to support multi-samples. This allows users to map multiple sampled sounds across keys or velocity ranges, creating more expressive and realistic instruments than single-sample playback alone. @@ -609,6 +613,8 @@ The SFZ file contains only the description of the multi-sample. The related samp SFZ can only mark a sample as a one-shot (`loop_mode=one_shot`) if it has no loop. A looped zone therefore keeps its loop and is not written as a one-shot. +A pitch LFO (vibrato) is read and written via the `pitchlfo_freq` (Hertz), `pitchlfo_depth` (cent), `pitchlfo_delay` and `pitchlfo_fade` (seconds) opcodes. + ### Source Options * Log unsupported SFZ opcodes: If enabled, opcodes which are found in the source but are not used (not supported) as input for the conversion are logged. @@ -627,6 +633,8 @@ The conversion process creates one destination file for each preset found in a S There are metadata fields for creator and some description specified in the format. However, additional information like a category is retrieved from Broadcast Audio Extension chunks in the WAV files. If no such chunks are present an [automatic detection](#automatic-metadata-detection) is applied. +The vibrato low frequency oscillator (generators `vibLfoToPitch`, `freqVibLFO` and `delayVibLFO`) is converted to and from the pitch LFO, carrying its depth, rate and delay. The waveform is always a triangle in this format and there is no fade-in. + ### Source Options * Log unused SF2 generators: If enabled, generators which are found in the source but are not used (not supported) as input for the conversion are logged. diff --git a/documentation/SupportedFeaturesSampleFormats.ods b/documentation/SupportedFeaturesSampleFormats.ods index a60f4bc9..933ca6a8 100644 Binary files a/documentation/SupportedFeaturesSampleFormats.ods and b/documentation/SupportedFeaturesSampleFormats.ods differ diff --git a/src/main/java/de/mossgrabers/convertwithmoss/core/model/ILfo.java b/src/main/java/de/mossgrabers/convertwithmoss/core/model/ILfo.java new file mode 100644 index 00000000..f493cb6d --- /dev/null +++ b/src/main/java/de/mossgrabers/convertwithmoss/core/model/ILfo.java @@ -0,0 +1,122 @@ +// Written by Jürgen Moßgraber - mossgrabers.de +// (c) 2019-2026 +// Licensed under LGPLv3 - http://www.gnu.org/licenses/lgpl-3.0.txt + +package de.mossgrabers.convertwithmoss.core.model; + +import de.mossgrabers.convertwithmoss.core.model.enumeration.LfoWaveform; + + +/** + * Interface to a low frequency oscillator. All times are given in seconds and the rate is given in + * Hertz, so that the values are comparable across formats. A format which stores a tempo + * synchronized rate needs to convert it, since the tempo is not part of a multi-sample. + * + * @author Jürgen Moßgraber + */ +public interface ILfo +{ + /** + * Get the waveform. + * + * @return The waveform + */ + LfoWaveform getWaveform (); + + + /** + * Set the waveform. + * + * @param waveform The waveform + */ + void setWaveform (LfoWaveform waveform); + + + /** + * Get the rate. + * + * @return The rate in Hertz or -1 if not set + */ + double getRate (); + + + /** + * Set the rate. + * + * @param rate The rate in Hertz + */ + void setRate (double rate); + + + /** + * Get the delay, which is the time before the modulation starts. + * + * @return The delay in seconds or -1 if not set + */ + double getDelay (); + + + /** + * Set the delay, which is the time before the modulation starts. + * + * @param delay The delay in seconds + */ + void setDelay (double delay); + + + /** + * Get the fade-in, which is the time it takes to reach the full modulation depth. + * + * @return The fade-in in seconds or -1 if not set + */ + double getFadeIn (); + + + /** + * Set the fade-in, which is the time it takes to reach the full modulation depth. + * + * @param fadeIn The fade-in in seconds + */ + void setFadeIn (double fadeIn); + + + /** + * Get the phase at which the waveform starts. + * + * @return The start phase in the range of [0..1] or -1 if not set + */ + double getStartPhase (); + + + /** + * Set the phase at which the waveform starts. + * + * @param startPhase The start phase in the range of [0..1] + */ + void setStartPhase (double startPhase); + + + /** + * Check if the oscillator restarts when a note is triggered. If it does not, it runs freely in + * the background. + * + * @return True if it restarts on a note + */ + boolean isKeySync (); + + + /** + * Set if the oscillator restarts when a note is triggered. + * + * @param isKeySync True if it restarts on a note + */ + void setKeySync (boolean isKeySync); + + + /** + * Check if any value was set. A rate needs to be present for the oscillator to be of any use. + * + * @return True if a rate was set + */ + boolean isSet (); +} diff --git a/src/main/java/de/mossgrabers/convertwithmoss/core/model/ILfoModulator.java b/src/main/java/de/mossgrabers/convertwithmoss/core/model/ILfoModulator.java new file mode 100644 index 00000000..3b265afc --- /dev/null +++ b/src/main/java/de/mossgrabers/convertwithmoss/core/model/ILfoModulator.java @@ -0,0 +1,29 @@ +// Written by Jürgen Moßgraber - mossgrabers.de +// (c) 2019-2026 +// Licensed under LGPLv3 - http://www.gnu.org/licenses/lgpl-3.0.txt + +package de.mossgrabers.convertwithmoss.core.model; + +/** + * Interface to a low frequency oscillator modulator. The modulation depth of the pitch modulation + * maps to -4800..4800 cent, which is the same range as the one of the pitch envelope modulator. + * + * @author Jürgen Moßgraber + */ +public interface ILfoModulator extends IModulator +{ + /** + * Get the modulation source. + * + * @return The modulation source, never null + */ + ILfo getSource (); + + + /** + * Set the modulation source. + * + * @param source The modulation source + */ + void setSource (ILfo source); +} diff --git a/src/main/java/de/mossgrabers/convertwithmoss/core/model/ISampleZone.java b/src/main/java/de/mossgrabers/convertwithmoss/core/model/ISampleZone.java index 1fed6aeb..100430f8 100644 --- a/src/main/java/de/mossgrabers/convertwithmoss/core/model/ISampleZone.java +++ b/src/main/java/de/mossgrabers/convertwithmoss/core/model/ISampleZone.java @@ -485,6 +485,15 @@ public interface ISampleZone IEnvelopeModulator getPitchEnvelopeModulator (); + /** + * Get the low frequency oscillator modulator for the pitch, which is commonly called vibrato. A + * depth of zero means that there is no vibrato. + * + * @return The modulator, never null + */ + ILfoModulator getPitchLfoModulator (); + + /** * Get pitch bend up value. * diff --git a/src/main/java/de/mossgrabers/convertwithmoss/core/model/enumeration/LfoWaveform.java b/src/main/java/de/mossgrabers/convertwithmoss/core/model/enumeration/LfoWaveform.java new file mode 100644 index 00000000..cce37cb0 --- /dev/null +++ b/src/main/java/de/mossgrabers/convertwithmoss/core/model/enumeration/LfoWaveform.java @@ -0,0 +1,27 @@ +// Written by Jürgen Moßgraber - mossgrabers.de +// (c) 2019-2026 +// Licensed under LGPLv3 - http://www.gnu.org/licenses/lgpl-3.0.txt + +package de.mossgrabers.convertwithmoss.core.model.enumeration; + +/** + * Different waveforms of a low frequency oscillator. Only the shapes which are present in most + * formats are listed, a format which knows further shapes needs to map them to the closest one. + * + * @author Jürgen Moßgraber + */ +public enum LfoWaveform +{ + /** A sine wave. */ + SINE, + /** A triangle wave. */ + TRIANGLE, + /** A square wave. */ + SQUARE, + /** A rising saw-tooth wave. */ + SAWTOOTH_UP, + /** A falling saw-tooth wave. */ + SAWTOOTH_DOWN, + /** A random (sample and hold) wave. */ + RANDOM, +} diff --git a/src/main/java/de/mossgrabers/convertwithmoss/core/model/implementation/DefaultLfo.java b/src/main/java/de/mossgrabers/convertwithmoss/core/model/implementation/DefaultLfo.java new file mode 100644 index 00000000..7f638e44 --- /dev/null +++ b/src/main/java/de/mossgrabers/convertwithmoss/core/model/implementation/DefaultLfo.java @@ -0,0 +1,152 @@ +// Written by Jürgen Moßgraber - mossgrabers.de +// (c) 2019-2026 +// Licensed under LGPLv3 - http://www.gnu.org/licenses/lgpl-3.0.txt + +package de.mossgrabers.convertwithmoss.core.model.implementation; + +import java.util.Objects; + +import de.mossgrabers.convertwithmoss.core.model.ILfo; +import de.mossgrabers.convertwithmoss.core.model.enumeration.LfoWaveform; + + +/** + * Default implementation of a low frequency oscillator. All values are unset by default, which + * means that a format which does not fill them writes nothing. + * + * @author Jürgen Moßgraber + */ +public class DefaultLfo implements ILfo +{ + private LfoWaveform waveform = LfoWaveform.TRIANGLE; + private double rate = -1; + private double delay = -1; + private double fadeIn = -1; + private double startPhase = -1; + private boolean isKeySync = false; + + + /** {@inheritDoc} */ + @Override + public LfoWaveform getWaveform () + { + return this.waveform; + } + + + /** {@inheritDoc} */ + @Override + public void setWaveform (final LfoWaveform waveform) + { + this.waveform = waveform; + } + + + /** {@inheritDoc} */ + @Override + public double getRate () + { + return this.rate; + } + + + /** {@inheritDoc} */ + @Override + public void setRate (final double rate) + { + this.rate = rate; + } + + + /** {@inheritDoc} */ + @Override + public double getDelay () + { + return this.delay; + } + + + /** {@inheritDoc} */ + @Override + public void setDelay (final double delay) + { + this.delay = delay; + } + + + /** {@inheritDoc} */ + @Override + public double getFadeIn () + { + return this.fadeIn; + } + + + /** {@inheritDoc} */ + @Override + public void setFadeIn (final double fadeIn) + { + this.fadeIn = fadeIn; + } + + + /** {@inheritDoc} */ + @Override + public double getStartPhase () + { + return this.startPhase; + } + + + /** {@inheritDoc} */ + @Override + public void setStartPhase (final double startPhase) + { + this.startPhase = startPhase; + } + + + /** {@inheritDoc} */ + @Override + public boolean isKeySync () + { + return this.isKeySync; + } + + + /** {@inheritDoc} */ + @Override + public void setKeySync (final boolean isKeySync) + { + this.isKeySync = isKeySync; + } + + + /** {@inheritDoc} */ + @Override + public boolean isSet () + { + return this.rate >= 0; + } + + + /** {@inheritDoc} */ + @Override + public int hashCode () + { + return Objects.hash (Double.valueOf (this.delay), Double.valueOf (this.fadeIn), Boolean.valueOf (this.isKeySync), Double.valueOf (this.rate), Double.valueOf (this.startPhase), this.waveform); + } + + + /** {@inheritDoc} */ + @Override + public boolean equals (final Object obj) + { + if (this == obj) + return true; + if (obj == null || this.getClass () != obj.getClass ()) + return false; + final DefaultLfo other = (DefaultLfo) obj; + return Double.doubleToLongBits (this.delay) == Double.doubleToLongBits (other.delay) && Double.doubleToLongBits (this.fadeIn) == Double.doubleToLongBits (other.fadeIn) && this.isKeySync == other.isKeySync && Double.doubleToLongBits (this.rate) == Double.doubleToLongBits (other.rate) && Double.doubleToLongBits (this.startPhase) == Double.doubleToLongBits (other.startPhase) && this.waveform == other.waveform; + } +} diff --git a/src/main/java/de/mossgrabers/convertwithmoss/core/model/implementation/DefaultLfoModulator.java b/src/main/java/de/mossgrabers/convertwithmoss/core/model/implementation/DefaultLfoModulator.java new file mode 100644 index 00000000..44ed59a7 --- /dev/null +++ b/src/main/java/de/mossgrabers/convertwithmoss/core/model/implementation/DefaultLfoModulator.java @@ -0,0 +1,75 @@ +// Written by Jürgen Moßgraber - mossgrabers.de +// (c) 2019-2026 +// Licensed under LGPLv3 - http://www.gnu.org/licenses/lgpl-3.0.txt + +package de.mossgrabers.convertwithmoss.core.model.implementation; + +import java.util.Objects; + +import de.mossgrabers.convertwithmoss.core.model.ILfo; +import de.mossgrabers.convertwithmoss.core.model.ILfoModulator; + + +/** + * Default implementation of a low frequency oscillator modulator. + * + * @author Jürgen Moßgraber + */ +public class DefaultLfoModulator extends DefaultModulator implements ILfoModulator +{ + private ILfo source = new DefaultLfo (); + + + /** + * Constructor. + * + * @param depth The modulation depth in the range of [-1,1]. A depth of 0 means that there is no + * modulation, which is the default for all formats which do not support it + */ + public DefaultLfoModulator (final double depth) + { + super (depth); + } + + + /** {@inheritDoc} */ + @Override + public ILfo getSource () + { + if (this.source == null) + this.source = new DefaultLfo (); + return this.source; + } + + + /** {@inheritDoc} */ + @Override + public void setSource (final ILfo source) + { + this.source = source; + } + + + /** {@inheritDoc} */ + @Override + public int hashCode () + { + final int prime = 31; + int result = super.hashCode (); + result = prime * result + Objects.hash (this.source); + return result; + } + + + /** {@inheritDoc} */ + @Override + public boolean equals (final Object obj) + { + if (this == obj) + return true; + if (!super.equals (obj) || this.getClass () != obj.getClass ()) + return false; + final DefaultLfoModulator other = (DefaultLfoModulator) obj; + return Objects.equals (this.source, other.source); + } +} diff --git a/src/main/java/de/mossgrabers/convertwithmoss/core/model/implementation/DefaultSampleZone.java b/src/main/java/de/mossgrabers/convertwithmoss/core/model/implementation/DefaultSampleZone.java index b2ddf4d4..c29b7026 100644 --- a/src/main/java/de/mossgrabers/convertwithmoss/core/model/implementation/DefaultSampleZone.java +++ b/src/main/java/de/mossgrabers/convertwithmoss/core/model/implementation/DefaultSampleZone.java @@ -10,6 +10,7 @@ import de.mossgrabers.convertwithmoss.core.model.IEnvelopeModulator; import de.mossgrabers.convertwithmoss.core.model.IFilter; +import de.mossgrabers.convertwithmoss.core.model.ILfoModulator; import de.mossgrabers.convertwithmoss.core.model.IModulator; import de.mossgrabers.convertwithmoss.core.model.ISampleData; import de.mossgrabers.convertwithmoss.core.model.ISampleLoop; @@ -54,6 +55,7 @@ public class DefaultSampleZone implements ISampleZone protected IModulator amplitudeVelocityModulator = new DefaultModulator (1); protected IEnvelopeModulator amplitudeEnvelopeModulator = new DefaultEnvelopeModulator (1); protected IEnvelopeModulator pitchModulator = new DefaultEnvelopeModulator (0); + protected ILfoModulator pitchLfoModulator = new DefaultLfoModulator (0); protected IFilter filter = null; protected List loops = new ArrayList<> (1); @@ -567,6 +569,14 @@ public IEnvelopeModulator getPitchEnvelopeModulator () } + /** {@inheritDoc} */ + @Override + public ILfoModulator getPitchLfoModulator () + { + return this.pitchLfoModulator; + } + + /** {@inheritDoc} */ @Override public Optional getFilter () @@ -614,6 +624,7 @@ public void fillMetadata (final ISampleZone other) this.amplitudeVelocityModulator = other.getAmplitudeVelocityModulator (); this.amplitudeEnvelopeModulator = other.getAmplitudeEnvelopeModulator (); this.pitchModulator = other.getPitchEnvelopeModulator (); + this.pitchLfoModulator = other.getPitchLfoModulator (); final Optional filterOpt = other.getFilter (); this.filter = filterOpt.isPresent () ? filterOpt.get () : null; diff --git a/src/main/java/de/mossgrabers/convertwithmoss/file/dls/DlsArticulation.java b/src/main/java/de/mossgrabers/convertwithmoss/file/dls/DlsArticulation.java index 2fd3edf6..c42f47e2 100644 --- a/src/main/java/de/mossgrabers/convertwithmoss/file/dls/DlsArticulation.java +++ b/src/main/java/de/mossgrabers/convertwithmoss/file/dls/DlsArticulation.java @@ -366,6 +366,18 @@ public int getSource () } + /** + * Get the controller which modulates the connection, e.g. the modulation wheel. A value of + * CONN_SRC_NONE means that the connection is always fully applied. + * + * @return The control, see the CONN_SRC_* constants + */ + public int getControl () + { + return this.control; + } + + /** * Get the destination. * @@ -401,6 +413,33 @@ public static double absoluteTimeToSeconds (final int value) } + /** + * Convert a relative pitch connection value to cent. The value is stored as a 32-bit fixed point + * number with 65536 representing one cent. + * + * @param value The raw 32-bit relative pitch value + * @return The pitch in cent + */ + public static double relativePitchToCents (final int value) + { + return value / 65536.0; + } + + + /** + * Convert an absolute pitch connection value to a frequency in Hertz. The value is stored as a + * 32-bit fixed point number with 65536 representing one cent, where 6900 cent equal 440 Hertz + * (which puts 0 cent at about 8.176 Hertz, the same reference as SoundFont). + * + * @param value The raw 32-bit absolute pitch value + * @return The frequency in Hertz + */ + public static double absolutePitchToHertz (final int value) + { + return 440.0 * Math.pow (2.0, (value / 65536.0 - 6900.0) / 1200.0); + } + + /** * Normalizes a DLS EG Sustain Level lScale value to the range 0.0..1.0. * diff --git a/src/main/java/de/mossgrabers/convertwithmoss/file/sf2/Generator.java b/src/main/java/de/mossgrabers/convertwithmoss/file/sf2/Generator.java index 5fc293dd..cc5665e9 100644 --- a/src/main/java/de/mossgrabers/convertwithmoss/file/sf2/Generator.java +++ b/src/main/java/de/mossgrabers/convertwithmoss/file/sf2/Generator.java @@ -23,6 +23,9 @@ public class Generator /** The ID of the end loop offset. */ public static final int END_LOOP_ADDRS_OFFSET = 3; + /** The ID of the vibrato low frequency oscillator to pitch generator. */ + public static final int VIB_LFO_TO_PITCH = 6; + /** The ID of the modulation envelope to pitch generator. */ public static final int MOD_ENV_TO_PITCH = 7; @@ -37,6 +40,11 @@ public class Generator /** The ID of the panning generator. */ public static final int PANNING = 17; + /** The ID of the vibrato low frequency oscillator delay generator (in time-cents). */ + public static final int DELAY_VIB_LFO = 23; + /** The ID of the vibrato low frequency oscillator frequency generator (in absolute cents). */ + public static final int FREQ_VIB_LFO = 24; + /** The ID of the modulation envelope delay generator. */ public static final int MOD_ENV_DELAY = 25; /** The ID of the modulation envelope attack generator. */ diff --git a/src/main/java/de/mossgrabers/convertwithmoss/format/decentsampler/DecentSamplerCreator.java b/src/main/java/de/mossgrabers/convertwithmoss/format/decentsampler/DecentSamplerCreator.java index 6c0999a3..b953da9c 100644 --- a/src/main/java/de/mossgrabers/convertwithmoss/format/decentsampler/DecentSamplerCreator.java +++ b/src/main/java/de/mossgrabers/convertwithmoss/format/decentsampler/DecentSamplerCreator.java @@ -37,9 +37,12 @@ import de.mossgrabers.convertwithmoss.core.model.IEnvelopeModulator; import de.mossgrabers.convertwithmoss.core.model.IFilter; import de.mossgrabers.convertwithmoss.core.model.IGroup; +import de.mossgrabers.convertwithmoss.core.model.ILfo; +import de.mossgrabers.convertwithmoss.core.model.ILfoModulator; import de.mossgrabers.convertwithmoss.core.model.ISampleLoop; import de.mossgrabers.convertwithmoss.core.model.ISampleZone; import de.mossgrabers.convertwithmoss.core.model.enumeration.FilterType; +import de.mossgrabers.convertwithmoss.core.model.enumeration.LfoWaveform; import de.mossgrabers.convertwithmoss.core.model.enumeration.PlayLogic; import de.mossgrabers.convertwithmoss.core.model.enumeration.TriggerType; import de.mossgrabers.convertwithmoss.core.model.implementation.DefaultFilter; @@ -342,7 +345,10 @@ else if (ampEnvParameterLevel == ParameterLevel.INSTRUMENT && groupIndex == 0 && this.createFilter (document, modulatorsElement, multisampleSource, groupElement, groupIndex); if (!zones.isEmpty ()) + { createPitchModulator (document, modulatorsElement, zones.get (0).getPitchEnvelopeModulator (), groupIndex); + createPitchLfoModulator (document, modulatorsElement, zones.get (0).getPitchLfoModulator (), groupIndex); + } } this.applyPolyphony (document, multisampleElement, groupsElement, multisampleSource); @@ -534,6 +540,55 @@ private void createFilter (final Document document, final Element modulatorsElem } + private static void createPitchLfoModulator (final Document document, final Element modulatorsElement, final ILfoModulator pitchLfoModulator, final int groupIndex) + { + final double lfoDepth = pitchLfoModulator.getDepth (); + if (lfoDepth == 0) + return; + + final ILfo pitchLfo = pitchLfoModulator.getSource (); + + final Element lfoElement = XMLUtils.addElement (document, modulatorsElement, DecentSamplerTag.LFO); + lfoElement.setAttribute (DecentSamplerTag.LFO_SHAPE, toLfoShape (pitchLfo.getWaveform ())); + // The modulation depth is applied through the binding range below, like the pitch envelope. + // A vibrato depth is small in relation to the full range, so more digits are needed here. + XMLUtils.setDoubleAttribute (lfoElement, DecentSamplerTag.MOD_AMOUNT, Math.abs (lfoDepth), 5); + final double rate = pitchLfo.getRate (); + if (rate > 0) + { + lfoElement.setAttribute (DecentSamplerTag.LFO_FREQUENCY_FORMAT, "hz"); + XMLUtils.setDoubleAttribute (lfoElement, DecentSamplerTag.LFO_FREQUENCY, rate, 3); + } + final double delay = pitchLfo.getDelay (); + if (delay > 0) + XMLUtils.setDoubleAttribute (lfoElement, DecentSamplerTag.LFO_DELAY_TIME, delay, 3); + + final Element bindingElement = XMLUtils.addElement (document, lfoElement, DecentSamplerTag.BINDING); + bindingElement.setAttribute ("type", "amp"); + bindingElement.setAttribute ("level", "group"); + bindingElement.setAttribute ("groupIndex", Integer.toString (groupIndex)); + bindingElement.setAttribute ("parameter", "GROUP_TUNING"); + bindingElement.setAttribute ("translation", "linear"); + // Unit are semi-tones; the oscillator is bipolar, so the full range is applied symmetrically + bindingElement.setAttribute ("translationOutputMin", Integer.toString (-IEnvelope.MAX_ENVELOPE_DEPTH / 100)); + bindingElement.setAttribute ("translationOutputMax", Integer.toString (IEnvelope.MAX_ENVELOPE_DEPTH / 100)); + bindingElement.setAttribute ("modBehavior", "add"); + } + + + private static String toLfoShape (final LfoWaveform waveform) + { + // DecentSampler only knows sine, square and saw. The triangle and random waveforms are + // mapped to the closest available shape. + return switch (waveform) + { + case SQUARE -> "square"; + case SAWTOOTH_UP, SAWTOOTH_DOWN -> "saw"; + default -> "sine"; + }; + } + + private static void createPitchModulator (final Document document, final Element modulatorsElement, final IEnvelopeModulator pitchModulator, final int groupIndex) { final double envelopeDepth = pitchModulator.getDepth (); diff --git a/src/main/java/de/mossgrabers/convertwithmoss/format/decentsampler/DecentSamplerDetector.java b/src/main/java/de/mossgrabers/convertwithmoss/format/decentsampler/DecentSamplerDetector.java index 89979169..fa9a7bc7 100644 --- a/src/main/java/de/mossgrabers/convertwithmoss/format/decentsampler/DecentSamplerDetector.java +++ b/src/main/java/de/mossgrabers/convertwithmoss/format/decentsampler/DecentSamplerDetector.java @@ -34,12 +34,16 @@ import de.mossgrabers.convertwithmoss.core.model.IEnvelopeModulator; import de.mossgrabers.convertwithmoss.core.model.IFilter; import de.mossgrabers.convertwithmoss.core.model.IGroup; +import de.mossgrabers.convertwithmoss.core.model.ILfo; +import de.mossgrabers.convertwithmoss.core.model.ILfoModulator; import de.mossgrabers.convertwithmoss.core.model.ISampleData; import de.mossgrabers.convertwithmoss.core.model.ISampleZone; import de.mossgrabers.convertwithmoss.core.model.enumeration.FilterType; +import de.mossgrabers.convertwithmoss.core.model.enumeration.LfoWaveform; import de.mossgrabers.convertwithmoss.core.model.enumeration.PlayLogic; import de.mossgrabers.convertwithmoss.core.model.enumeration.TriggerType; import de.mossgrabers.convertwithmoss.core.model.implementation.DefaultEnvelopeModulator; +import de.mossgrabers.convertwithmoss.core.model.implementation.DefaultLfoModulator; import de.mossgrabers.convertwithmoss.core.model.implementation.DefaultFilter; import de.mossgrabers.convertwithmoss.core.model.implementation.DefaultGroup; import de.mossgrabers.convertwithmoss.core.model.implementation.DefaultSampleLoop; @@ -474,6 +478,7 @@ private void parseGroup (final Element topElement, final IGroup group, final Ele // IMPROVE: Should be added to group itself but needs to be adapted in all other formats final Optional optFilter = parseFilterEffect (topElement, groupElement); final Optional pitchModulation = parsePitchModulation (topElement); + final Optional pitchLfoModulation = parsePitchLfoModulation (topElement); for (final Element sampleElement: XMLUtils.getChildElementsByName (groupElement, DecentSamplerTag.SAMPLE, false)) { @@ -511,6 +516,13 @@ private void parseGroup (final Element topElement, final IGroup group, final Ele pitchModulator.setDepth (envelopeModulator.getDepth ()); pitchModulator.setSource (envelopeModulator.getSource ()); } + if (pitchLfoModulation.isPresent ()) + { + final ILfoModulator lfoModulator = pitchLfoModulation.get (); + final ILfoModulator pitchLfoModulator = sampleZone.getPitchLfoModulator (); + pitchLfoModulator.setDepth (lfoModulator.getDepth ()); + pitchLfoModulator.setSource (lfoModulator.getSource ()); + } group.addSampleZone (sampleZone); } @@ -660,6 +672,48 @@ private static Optional parsePitchModulation (final Element } + private static Optional parsePitchLfoModulation (final Element topElement) + { + // Parse a low frequency oscillator bound to the pitch (vibrato). The oscillator bound to the + // global tuning in the template is a mod-wheel routing and is intentionally not matched. + final Element modulatorsElement = XMLUtils.getChildElementByName (topElement, DecentSamplerTag.MODULATORS); + if (modulatorsElement != null) + for (final Element lfoElement: XMLUtils.getChildElementsByName (modulatorsElement, DecentSamplerTag.LFO)) + { + final Element bindingElement = XMLUtils.getChildElementByName (lfoElement, DecentSamplerTag.BINDING); + if (bindingElement == null || !"GROUP_TUNING".equals (bindingElement.getAttribute ("parameter"))) + continue; + + final double depth = XMLUtils.getDoubleAttribute (lfoElement, DecentSamplerTag.MOD_AMOUNT, 0); + if (depth == 0) + continue; + + final ILfoModulator pitchLfoModulator = new DefaultLfoModulator (depth); + final ILfo pitchLfo = pitchLfoModulator.getSource (); + pitchLfo.setWaveform (toLfoWaveform (lfoElement.getAttribute (DecentSamplerTag.LFO_SHAPE))); + // Only a frequency given in Hertz can be converted; a tempo synchronized rate has no + // representation without a tempo + final String frequencyFormat = lfoElement.getAttribute (DecentSamplerTag.LFO_FREQUENCY_FORMAT); + if (frequencyFormat.isEmpty () || "hz".equals (frequencyFormat)) + pitchLfo.setRate (XMLUtils.getDoubleAttribute (lfoElement, DecentSamplerTag.LFO_FREQUENCY, -1)); + pitchLfo.setDelay (XMLUtils.getDoubleAttribute (lfoElement, DecentSamplerTag.LFO_DELAY_TIME, -1)); + return Optional.of (pitchLfoModulator); + } + return Optional.empty (); + } + + + private static LfoWaveform toLfoWaveform (final String shape) + { + return switch (shape) + { + case "square" -> LfoWaveform.SQUARE; + case "saw" -> LfoWaveform.SAWTOOTH_UP; + default -> LfoWaveform.SINE; + }; + } + + /** * Get the value of a note element. The value can be either an integer MIDI note or a text like * C#5. diff --git a/src/main/java/de/mossgrabers/convertwithmoss/format/decentsampler/DecentSamplerTag.java b/src/main/java/de/mossgrabers/convertwithmoss/format/decentsampler/DecentSamplerTag.java index 5e1074ac..f1f9645a 100644 --- a/src/main/java/de/mossgrabers/convertwithmoss/format/decentsampler/DecentSamplerTag.java +++ b/src/main/java/de/mossgrabers/convertwithmoss/format/decentsampler/DecentSamplerTag.java @@ -31,6 +31,16 @@ public class DecentSamplerTag public static final String MODULATORS = "modulators"; /** The envelope tag. */ public static final String ENVELOPE = "envelope"; + /** The low frequency oscillator tag. */ + public static final String LFO = "lfo"; + /** The LFO shape attribute. */ + public static final String LFO_SHAPE = "shape"; + /** The LFO frequency attribute. */ + public static final String LFO_FREQUENCY = "frequency"; + /** The LFO frequency format attribute. */ + public static final String LFO_FREQUENCY_FORMAT = "frequencyFormat"; + /** The LFO delay time attribute. */ + public static final String LFO_DELAY_TIME = "delayTime"; /** The user interface tag. */ public static final String UI = "ui"; diff --git a/src/main/java/de/mossgrabers/convertwithmoss/format/dls/DlsDetector.java b/src/main/java/de/mossgrabers/convertwithmoss/format/dls/DlsDetector.java index be36f148..6e38334d 100644 --- a/src/main/java/de/mossgrabers/convertwithmoss/format/dls/DlsDetector.java +++ b/src/main/java/de/mossgrabers/convertwithmoss/format/dls/DlsDetector.java @@ -19,6 +19,8 @@ import de.mossgrabers.convertwithmoss.core.model.IEnvelope; import de.mossgrabers.convertwithmoss.core.model.IEnvelopeModulator; import de.mossgrabers.convertwithmoss.core.model.IGroup; +import de.mossgrabers.convertwithmoss.core.model.ILfo; +import de.mossgrabers.convertwithmoss.core.model.ILfoModulator; import de.mossgrabers.convertwithmoss.core.model.IMetadata; import de.mossgrabers.convertwithmoss.core.model.ISampleZone; import de.mossgrabers.convertwithmoss.core.model.implementation.DefaultEnvelope; @@ -242,6 +244,28 @@ private static void applyArticulations (final DlsInstrument dlsInstrument, final pitchEnvelopeModulator.setSource (pitchEnvelope); } + // Pitch LFO (vibrato). The low frequency oscillator which modulates the pitch is the + // vibrato; its frequency and delay are set by their own connections. + // Only an *uncontrolled* connection is read: the format additionally defines the same + // connection controlled by the modulation wheel, which is the amount the wheel can dial in + // and must not be applied as a permanently sounding vibrato. + final Optional pitchLfoModulation = getUncontrolledArticulation (dlsInstrument, dlsRegion, DlsArticulation.CONN_SRC_LFO, DlsArticulation.CONN_DST_PITCH); + if (pitchLfoModulation.isPresent ()) + { + final double depthCents = DlsArticulation.relativePitchToCents (pitchLfoModulation.get ().getScale ()); + if (depthCents != 0) + { + final ILfoModulator pitchLfoModulator = zone.getPitchLfoModulator (); + pitchLfoModulator.setDepth (Math.clamp (depthCents, -IEnvelope.MAX_ENVELOPE_DEPTH, IEnvelope.MAX_ENVELOPE_DEPTH) / IEnvelope.MAX_ENVELOPE_DEPTH); + + final ILfo pitchLfo = pitchLfoModulator.getSource (); + final Optional lfoFrequency = getArticulation (dlsInstrument, dlsRegion, DlsArticulation.CONN_SRC_NONE, DlsArticulation.CONN_DST_LFO_FREQUENCY); + if (lfoFrequency.isPresent ()) + pitchLfo.setRate (DlsArticulation.absolutePitchToHertz (lfoFrequency.get ().getScale ())); + pitchLfo.setDelay (getTime (dlsInstrument, dlsRegion, DlsArticulation.CONN_DST_LFO_STARTDELAY)); + } + } + // Pitch tuning final Optional pitchTuning = getArticulation (dlsInstrument, dlsRegion, DlsArticulation.CONN_SRC_NONE, DlsArticulation.CONN_DST_PITCH); if (pitchTuning.isPresent ()) @@ -264,6 +288,33 @@ private static Optional getArticulation (final DlsInstrument dl } + /** + * Get a connection which is not modulated by a controller, e.g. the modulation wheel. + * + * @param dlsInstrument The instrument + * @param dlsRegion The region + * @param source The source, see the CONN_SRC_* constants + * @param destination The destination, see the CONN_DST_* constants + * @return The connection, if any + */ + private static Optional getUncontrolledArticulation (final DlsInstrument dlsInstrument, final DlsRegion dlsRegion, final int source, final int destination) + { + final Optional articulation = getUncontrolledArticulation (dlsRegion.getArticulations (), source, destination); + if (articulation.isPresent ()) + return articulation; + return getUncontrolledArticulation (dlsInstrument.getArticulations (), source, destination); + } + + + private static Optional getUncontrolledArticulation (final List articulations, final int source, final int destination) + { + for (final DlsArticulation articulation: articulations) + if (articulation.getSource () == source && articulation.getDestination () == destination && articulation.getControl () == DlsArticulation.CONN_SRC_NONE) + return Optional.of (articulation); + return Optional.empty (); + } + + private static Optional getArticulation (final List articulations, final int source, final int destination) { for (final DlsArticulation articulation: articulations) diff --git a/src/main/java/de/mossgrabers/convertwithmoss/format/sf2/Sf2Creator.java b/src/main/java/de/mossgrabers/convertwithmoss/format/sf2/Sf2Creator.java index c24056b9..048968ee 100644 --- a/src/main/java/de/mossgrabers/convertwithmoss/format/sf2/Sf2Creator.java +++ b/src/main/java/de/mossgrabers/convertwithmoss/format/sf2/Sf2Creator.java @@ -22,6 +22,8 @@ import de.mossgrabers.convertwithmoss.core.model.IEnvelope; import de.mossgrabers.convertwithmoss.core.model.IEnvelopeModulator; import de.mossgrabers.convertwithmoss.core.model.IFilter; +import de.mossgrabers.convertwithmoss.core.model.ILfo; +import de.mossgrabers.convertwithmoss.core.model.ILfoModulator; import de.mossgrabers.convertwithmoss.core.model.IGroup; import de.mossgrabers.convertwithmoss.core.model.IMetadata; import de.mossgrabers.convertwithmoss.core.model.ISampleData; @@ -421,6 +423,26 @@ else if (sampleType == Sf2SampleDescriptor.RIGHT) setEnvelopeKeyTracking (instrumentZone, Generator.KEYNUM_TO_MOD_ENV_HOLD, Generator.KEYNUM_TO_MOD_ENV_DECAY, pitchEnvelope.getTimeKeyTracking ()); } + // Set the vibrato low frequency oscillator from the pitch modulation. The depth is given in + // cent like the pitch envelope, the fade-in and the waveform have no equivalent in Sf2. + final ILfoModulator pitchLfoModulator = sampleZone.getPitchLfoModulator (); + final double vibLfoDepth = pitchLfoModulator.getDepth (); + if (vibLfoDepth != 0) + { + instrumentZone.addSignedGenerator (Generator.VIB_LFO_TO_PITCH, (int) Math.round (vibLfoDepth * IEnvelope.MAX_ENVELOPE_DEPTH)); + final ILfo pitchLfo = pitchLfoModulator.getSource (); + final double rate = pitchLfo.getRate (); + if (rate > 0) + { + // The frequency is stored in absolute cents, see the filter cutoff below + final double frequencyCents = Math.log (rate / 8.176) * 1200.0 / Math.log (2); + instrumentZone.addSignedGenerator (Generator.FREQ_VIB_LFO, (int) Math.round (frequencyCents)); + } + final double delay = pitchLfo.getDelay (); + if (delay >= 0) + instrumentZone.addSignedGenerator (Generator.DELAY_VIB_LFO, convertEnvelopeTime (delay)); + } + // Filter settings final Optional filterOpt = sampleZone.getFilter (); if (filterOpt.isPresent ()) diff --git a/src/main/java/de/mossgrabers/convertwithmoss/format/sf2/Sf2Detector.java b/src/main/java/de/mossgrabers/convertwithmoss/format/sf2/Sf2Detector.java index 88848e9d..8158fc4b 100644 --- a/src/main/java/de/mossgrabers/convertwithmoss/format/sf2/Sf2Detector.java +++ b/src/main/java/de/mossgrabers/convertwithmoss/format/sf2/Sf2Detector.java @@ -19,6 +19,8 @@ import de.mossgrabers.convertwithmoss.core.model.IEnvelope; import de.mossgrabers.convertwithmoss.core.model.IEnvelopeModulator; import de.mossgrabers.convertwithmoss.core.model.IFilter; +import de.mossgrabers.convertwithmoss.core.model.ILfo; +import de.mossgrabers.convertwithmoss.core.model.ILfoModulator; import de.mossgrabers.convertwithmoss.core.model.IGroup; import de.mossgrabers.convertwithmoss.core.model.IMetadata; import de.mossgrabers.convertwithmoss.core.model.ISampleData; @@ -849,6 +851,19 @@ private static Optional createSampleZone (final Sf2SampleDescriptor pitchEnvelope.setTimeKeyTracking (convertEnvelopeKeyTracking (generators.getSignedValue (Generator.KEYNUM_TO_MOD_ENV_DECAY), generators.getSignedValue (Generator.KEYNUM_TO_MOD_ENV_HOLD))); } + // The vibrato low frequency oscillator maps to the pitch modulation. Its waveform is + // always a triangle and the depth is given in cent, like the pitch envelope. + final ILfoModulator pitchLfoModulator = zone.getPitchLfoModulator (); + final int vibLfoDepth = generators.getSignedValue (Generator.VIB_LFO_TO_PITCH).intValue (); + pitchLfoModulator.setDepth (vibLfoDepth / (double) IEnvelope.MAX_ENVELOPE_DEPTH); + if (vibLfoDepth != 0) + { + final ILfo pitchLfo = pitchLfoModulator.getSource (); + // The frequency is stored in absolute cents, see the filter cutoff above + pitchLfo.setRate (8.176 * Math.pow (2, generators.getSignedValue (Generator.FREQ_VIB_LFO).doubleValue () / 1200.0)); + pitchLfo.setDelay (convertEnvelopeTime (generators.getSignedValue (Generator.DELAY_VIB_LFO))); + } + return Optional.of (zone); } catch (final IOException _) diff --git a/src/main/java/de/mossgrabers/convertwithmoss/format/sfz/SfzCreator.java b/src/main/java/de/mossgrabers/convertwithmoss/format/sfz/SfzCreator.java index 550b24ec..d8fbce11 100644 --- a/src/main/java/de/mossgrabers/convertwithmoss/format/sfz/SfzCreator.java +++ b/src/main/java/de/mossgrabers/convertwithmoss/format/sfz/SfzCreator.java @@ -28,6 +28,8 @@ import de.mossgrabers.convertwithmoss.core.model.IEnvelopeModulator; import de.mossgrabers.convertwithmoss.core.model.IFilter; import de.mossgrabers.convertwithmoss.core.model.IGroup; +import de.mossgrabers.convertwithmoss.core.model.ILfo; +import de.mossgrabers.convertwithmoss.core.model.ILfoModulator; import de.mossgrabers.convertwithmoss.core.model.IMetadata; import de.mossgrabers.convertwithmoss.core.model.ISampleData; import de.mossgrabers.convertwithmoss.core.model.ISampleLoop; @@ -373,6 +375,24 @@ private void createSample (final String safeSampleFolderName, final StringBuilde buffer.append (envelopeStr).append (LINE_FEED); } + // ----------------------------------------------------------- + // Pitch LFO (vibrato) + + final ILfoModulator pitchLfoModulator = zone.getPitchLfoModulator (); + final double lfoDepth = pitchLfoModulator.getDepth (); + if (lfoDepth != 0) + { + final StringBuilder lfoStr = new StringBuilder (); + lfoStr.append (SfzOpcode.PITCHLFO_DEPTH).append ('=').append ((int) Math.round (lfoDepth * IEnvelope.MAX_ENVELOPE_DEPTH)); + + final ILfo pitchLfo = pitchLfoModulator.getSource (); + addLfoTimeAttribute (lfoStr, SfzOpcode.PITCHLFO_FREQ, pitchLfo.getRate ()); + addLfoTimeAttribute (lfoStr, SfzOpcode.PITCHLFO_DELAY, pitchLfo.getDelay ()); + addLfoTimeAttribute (lfoStr, SfzOpcode.PITCHLFO_FADE, pitchLfo.getFadeIn ()); + + buffer.append (lfoStr).append (LINE_FEED); + } + // ----------------------------------------------------------- // Sample Loop @@ -595,6 +615,16 @@ private static void addEnvelopeLevelAttribute (final StringBuilder sb, final Str } + private static void addLfoTimeAttribute (final StringBuilder sb, final String opcode, final double value) + { + if (value < 0) + return; + if (!sb.isEmpty ()) + sb.append (' '); + sb.append (opcode).append ('=').append (formatAsFloat (value)); + } + + private static void addSlopeAttribute (final StringBuilder sb, final String opcode, final double value) { if (value == 0) diff --git a/src/main/java/de/mossgrabers/convertwithmoss/format/sfz/SfzDetector.java b/src/main/java/de/mossgrabers/convertwithmoss/format/sfz/SfzDetector.java index de17fb4f..d90a4d7f 100644 --- a/src/main/java/de/mossgrabers/convertwithmoss/format/sfz/SfzDetector.java +++ b/src/main/java/de/mossgrabers/convertwithmoss/format/sfz/SfzDetector.java @@ -27,6 +27,8 @@ import de.mossgrabers.convertwithmoss.core.model.IEnvelopeModulator; import de.mossgrabers.convertwithmoss.core.model.IFilter; import de.mossgrabers.convertwithmoss.core.model.IGroup; +import de.mossgrabers.convertwithmoss.core.model.ILfo; +import de.mossgrabers.convertwithmoss.core.model.ILfoModulator; import de.mossgrabers.convertwithmoss.core.model.ISampleData; import de.mossgrabers.convertwithmoss.core.model.ISampleLoop; import de.mossgrabers.convertwithmoss.core.model.ISampleZone; @@ -500,6 +502,22 @@ private void parseRegion (final ISampleZone sampleMetadata) pitchEnvelope.setDecaySlope (this.getDoubleValue (SfzOpcode.PITCHEG_DECAY_SHAPE, 0) / 10.0); pitchEnvelope.setReleaseSlope (this.getDoubleValue (SfzOpcode.PITCHEG_RELEASE_SHAPE, 0) / 10.0); + // ----------------------------------------------------------- + // Pitch LFO (vibrato) + + // Without a depth there is no modulation, therefore the oscillator is not read at all + final double lfoDepth = this.getDoubleValue (SfzOpcode.PITCHLFO_DEPTH, 0); + if (lfoDepth != 0) + { + final ILfoModulator pitchLfoModulator = sampleMetadata.getPitchLfoModulator (); + pitchLfoModulator.setDepth (lfoDepth / IEnvelope.MAX_ENVELOPE_DEPTH); + + final ILfo pitchLfo = pitchLfoModulator.getSource (); + pitchLfo.setRate (this.getDoubleValue (SfzOpcode.PITCHLFO_FREQ, -1)); + pitchLfo.setDelay (this.getDoubleValue (SfzOpcode.PITCHLFO_DELAY, -1)); + pitchLfo.setFadeIn (this.getDoubleValue (SfzOpcode.PITCHLFO_FADE, -1)); + } + // ----------------------------------------------------------- // Volume diff --git a/src/main/java/de/mossgrabers/convertwithmoss/format/sfz/SfzOpcode.java b/src/main/java/de/mossgrabers/convertwithmoss/format/sfz/SfzOpcode.java index 7cd5b219..cf8571e2 100644 --- a/src/main/java/de/mossgrabers/convertwithmoss/format/sfz/SfzOpcode.java +++ b/src/main/java/de/mossgrabers/convertwithmoss/format/sfz/SfzOpcode.java @@ -261,6 +261,15 @@ public class SfzOpcode /** ARIA. The pitch EG release slope time. */ public static final String PITCHEG_RELEASE_SHAPE = "pitcheg_release_shape"; + /** SFZ v1. The pitch LFO (vibrato) frequency in Hertz. */ + public static final String PITCHLFO_FREQ = "pitchlfo_freq"; + /** SFZ v1. The pitch LFO (vibrato) depth in cent. */ + public static final String PITCHLFO_DEPTH = "pitchlfo_depth"; + /** SFZ v1. The pitch LFO (vibrato) delay time in seconds. */ + public static final String PITCHLFO_DELAY = "pitchlfo_delay"; + /** SFZ v1. The pitch LFO (vibrato) fade-in time in seconds. */ + public static final String PITCHLFO_FADE = "pitchlfo_fade"; + /** * Private constructor for utility class.