diff --git a/documentation/CHANGELOG.md b/documentation/CHANGELOG.md index 935e021d..ccff9612 100644 --- a/documentation/CHANGELOG.md +++ b/documentation/CHANGELOG.md @@ -11,12 +11,14 @@ * New: Added several new tags for category detection. * Fixed: Converting into the root of a USB stick or external drive could fail with "The output folder is not empty" even when it looked empty, because the operating system keeps hidden bookkeeping files there (e.g. .Spotlight-V100, .Trashes and .fseventsd on macOS). Hidden files/folders and the known Windows system folders are now ignored by the empty-folder check (thanks to Douglas Carmichael). * Synthstrom Deluge (thanks to Douglas Carmichael) + * New: Filter and amplitude modulations stored as patch cables are now read so they can be carried over to formats that support them: filter cutoff keyboard-tracking (the "note" source routed to the filter frequency), the filter modulation envelope (envelope2 routed to the filter frequency), filter velocity (velocity routed to the filter frequency) and amplitude velocity (velocity routed to the volume). * Fixed: Sample-based SOUND and KIT files were rejected with "This is not a Deluge KIT or SOUND file" when the root tag carried attributes (e.g. `firmwareVersion` written inline by firmware 3.x). The broken-XML workaround only matched the bare ``/`` tag; the root tag is now matched with or without attributes. * FLAC/OGG * Fixed: FLAC or OGG samples stored inside a ZIP archive (e.g. discoDSP Bliss or DecentSampler libraries) could fail to decompress. * Fixed: Stereo (multi-channel) samples stored in a compressed format were truncated to half their length when decompressed while writing to an uncompressed destination. * Fixed: Implemented workaround for converting 32-bit FLAC files (might not always work). * Waldorf Quantum/Iridium (thanks to Douglas Carmichael) + * New: The filter cutoff keyboard-tracking is now written (Filter1Keytrack/Filter2Keytrack), so e.g. a converted Synthstrom Deluge patch keeps its brightness across the keyboard range instead of sounding dark in the upper octaves. * Fixed: A very short envelope time (at or below 0.06 seconds - in particular a zero attack, decay or release) was written as an out-of-range parameter value; exactly zero produced negative infinity. The corrupt value could cause a click at the start of every note on the device. Such times are now clamped to the shortest representable value. * Fixed: An amplitude envelope with no attack and no decay that sustains below full level popped at the start of every note - the device snapped to the 100% attack peak and instantly dropped to the sustain level. Such an envelope is now written flat (full sustain) with the sustain level folded into the sample gain, so the loudness is unchanged but the discontinuity is gone. * Fixed: A sample zone without an explicit start/end (e.g. converted from a format that stores only loop points) was written with a sample start and end of -1; the whole sample is now used. diff --git a/documentation/README-FORMATS.md b/documentation/README-FORMATS.md index 7c74a50c..8cd375f4 100644 --- a/documentation/README-FORMATS.md +++ b/documentation/README-FORMATS.md @@ -584,7 +584,7 @@ then move the prt_omn file to The Synthstrom Audible Deluge is a standalone hardware synthesizer, sampler and sequencer. Its sounds are stored as XML files (file ending *xml*) on the SD-card; a *sound* holds a (multi-)sample based synth voice and a *kit* holds a set of drums. The samples themselves are referenced by a path which is relative to the SD-card root (e.g. *SAMPLES/My Patch/C3.wav*). Both reading and writing are supported. -The Deluge has gone through several firmware generations which changed how the XML is written: the official firmware (up to v4) stores the parameters as child elements while the community firmware additionally writes them as attributes. When reading, **both** variants - and both *sound* and *kit* files - are understood, so patches authored on either firmware are translated without losing information. The sample mapping (key ranges), the original root note (from the *transpose* / *cents* offset, or the fixed root note of a drum), the sample playback start/end and loop points, the loop mode (one-shot or loop), the reversed flag, the low- and high-pass filter (with the various filter modes) as well as the amplitude envelope and the velocity-to-volume modulation are converted. +The Deluge has gone through several firmware generations which changed how the XML is written: the official firmware (up to v4) stores the parameters as child elements while the community firmware additionally writes them as attributes. When reading, **both** variants - and both *sound* and *kit* files - are understood, so patches authored on either firmware are translated without losing information. The sample mapping (key ranges), the original root note (from the *transpose* / *cents* offset, or the fixed root note of a drum), the sample playback start/end and loop points, the loop mode (one-shot or loop), the reversed flag, the low- and high-pass filter (with the various filter modes), the amplitude and filter envelopes, and the filter keyboard-tracking, filter velocity and velocity-to-volume modulations are converted. When writing, a multi-sample is stored as a *sound* patch with a single sample oscillator whose zones are written as a *sampleRanges* list. The file is written in the **element-based form of the official v4 firmware** (`firmwareVersion="4.1.0-alpha"`), so it loads on both the official v4 firmware and on the community firmware - no community-only features are used. The output mirrors the Deluge SD-card layout: the patch is written to a *SYNTHS* sub-folder and its samples to *SAMPLES/<name>/* next to it, and the *fileName* references are written relative to that card root. @@ -598,6 +598,7 @@ The Deluge has no loop cross-fade parameter of its own, so - exactly like the Re * A Deluge *sound* has a single sample oscillator with one sample per key, therefore only one velocity layer is written. If a source contains several velocity layers, the loudest zone of each key is kept and the others are ignored. * The amplitude envelope attack, decay and release times are converted using the Deluge's internal rate tables (attack about 0.7 ms .. 3 s, decay/release about 6 ms .. 6 s); times outside that range are clamped. The filter cut-off / resonance are stored as the Deluge's internal 32-bit parameter values and are a musically faithful approximation rather than an exact match. +* Effects and device-specific modulation are not converted. The Deluge's reverb, delay, chorus / mod-FX, distortion, EQ, sidechain/compressor and arpeggiator are outside the multi-sample model, and modulation sources such as the LFOs are not carried because their parameters (rate ranges, shapes, sync, destinations) differ from one sampler to the next and have no portable representation. A patch that relies on them - for example a pad whose long tail comes from reverb and delay rather than a long amplitude release - therefore sounds drier or shorter after conversion, even though its oscillator, envelopes and filter are translated faithfully. ## TAL Sampler diff --git a/src/main/java/de/mossgrabers/convertwithmoss/core/model/IFilter.java b/src/main/java/de/mossgrabers/convertwithmoss/core/model/IFilter.java index 9fb85983..d2e61a6e 100644 --- a/src/main/java/de/mossgrabers/convertwithmoss/core/model/IFilter.java +++ b/src/main/java/de/mossgrabers/convertwithmoss/core/model/IFilter.java @@ -67,4 +67,22 @@ public interface IFilter * @return The modulator */ IEnvelopeModulator getCutoffEnvelopeModulator (); + + + /** + * Get the keyboard tracking amount applied to the filter cutoff. A value of 0 means no tracking, + * 1 means the cutoff follows the played note one-to-one (one semitone per semitone, +100%) and + * -1 means inverse tracking (-100%). + * + * @return The key-tracking amount in the range of [-1..1] + */ + double getCutoffKeyTracking (); + + + /** + * Set the keyboard tracking amount applied to the filter cutoff. + * + * @param keyTracking The key-tracking amount in the range of [-1..1] (0 = no tracking) + */ + void setCutoffKeyTracking (double keyTracking); } diff --git a/src/main/java/de/mossgrabers/convertwithmoss/core/model/implementation/DefaultFilter.java b/src/main/java/de/mossgrabers/convertwithmoss/core/model/implementation/DefaultFilter.java index e0b0986f..0471dffc 100644 --- a/src/main/java/de/mossgrabers/convertwithmoss/core/model/implementation/DefaultFilter.java +++ b/src/main/java/de/mossgrabers/convertwithmoss/core/model/implementation/DefaultFilter.java @@ -22,6 +22,7 @@ public class DefaultFilter implements IFilter protected double cutoff; protected double resonance; protected int envelopeDepth; + protected double cutoffKeyTracking = 0; protected IEnvelopeModulator cutoffEnvelopeModulator = new DefaultEnvelopeModulator (0); protected IModulator cutoffVelocityModulator = new DefaultModulator (0); @@ -91,6 +92,22 @@ public IEnvelopeModulator getCutoffEnvelopeModulator () } + /** {@inheritDoc} */ + @Override + public double getCutoffKeyTracking () + { + return this.cutoffKeyTracking; + } + + + /** {@inheritDoc} */ + @Override + public void setCutoffKeyTracking (final double keyTracking) + { + this.cutoffKeyTracking = keyTracking; + } + + /** {@inheritDoc} */ @Override public int hashCode () @@ -103,6 +120,8 @@ public int hashCode () result = prime * result + (this.cutoffVelocityModulator == null ? 0 : this.cutoffVelocityModulator.hashCode ()); result = prime * result + (this.cutoffEnvelopeModulator == null ? 0 : this.cutoffEnvelopeModulator.hashCode ()); result = prime * result + this.envelopeDepth; + temp = Double.doubleToLongBits (this.cutoffKeyTracking); + result = prime * result + (int) (temp ^ temp >>> 32); result = prime * result + this.poles; temp = Double.doubleToLongBits (this.resonance); result = prime * result + (int) (temp ^ temp >>> 32); @@ -138,6 +157,8 @@ else if (!this.cutoffEnvelopeModulator.equals (other.cutoffEnvelopeModulator)) return false; if (this.envelopeDepth != other.envelopeDepth || this.poles != other.poles || Double.doubleToLongBits (this.resonance) != Double.doubleToLongBits (other.resonance)) return false; + if (Double.doubleToLongBits (this.cutoffKeyTracking) != Double.doubleToLongBits (other.cutoffKeyTracking)) + return false; return this.type == other.type; } } diff --git a/src/main/java/de/mossgrabers/convertwithmoss/format/synthstrom/DelugeDetector.java b/src/main/java/de/mossgrabers/convertwithmoss/format/synthstrom/DelugeDetector.java index cf67bf8e..6d754c0f 100644 --- a/src/main/java/de/mossgrabers/convertwithmoss/format/synthstrom/DelugeDetector.java +++ b/src/main/java/de/mossgrabers/convertwithmoss/format/synthstrom/DelugeDetector.java @@ -23,6 +23,7 @@ import de.mossgrabers.convertwithmoss.core.detector.DefaultMultisampleSource; import de.mossgrabers.convertwithmoss.core.model.IAudioMetadata; 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.IGroup; import de.mossgrabers.convertwithmoss.core.model.IMetadata; @@ -516,14 +517,22 @@ private static void applySoundParameters (final Element soundElement, final List return; final Element envelope1 = getDirectChild (defaultParams, DelugeTag.ENVELOPE1); - if (envelope1 != null) - for (final ISampleZone zone: zones) - readEnvelope (envelope1, zone.getAmplitudeEnvelopeModulator ().getSource ()); - - final IFilter filter = readFilter (soundElement, defaultParams); - if (filter != null) - for (final ISampleZone zone: zones) - zone.setFilter (new DefaultFilter (filter.getType (), filter.getPoles (), filter.getCutoff (), filter.getResonance ())); + // The Deluge expresses velocity sensitivity of the amplitude as a "velocity" patch cable + // routed to the post-effects volume. + final double amplitudeVelocityDepth = readPatchCableDepth (defaultParams, DelugeTag.SOURCE_VELOCITY, DelugeTag.DESTINATION_VOLUME); + + for (final ISampleZone zone: zones) + { + final IEnvelopeModulator amplitudeModulator = zone.getAmplitudeEnvelopeModulator (); + if (envelope1 != null) + readEnvelope (envelope1, amplitudeModulator.getSource ()); + amplitudeModulator.setDepth (amplitudeVelocityDepth); + + // A fresh filter is read per zone so the zones do not share mutable modulators. + final IFilter filter = readFilter (soundElement, defaultParams); + if (filter != null) + zone.setFilter (filter); + } } @@ -573,7 +582,9 @@ private static IFilter readFilter (final Element soundElement, final Element def { final String lpfResonance = nestedLpf != null ? getValue (nestedLpf, DelugeTag.RESONANCE) : getValue (defaultParams, DelugeTag.LPF_RESONANCE); final double resonance = DelugeValues.paramToLevel (DelugeValues.parseValue (lpfResonance, DelugeValues.PARAM_MIN)); - return createFilter (getValue (soundElement, DelugeTag.LPF_MODE), FilterType.LOW_PASS, 4, DelugeValues.paramToCutoff (frequencyParam), resonance); + final IFilter filter = createFilter (getValue (soundElement, DelugeTag.LPF_MODE), FilterType.LOW_PASS, 4, DelugeValues.paramToCutoff (frequencyParam), resonance); + applyFilterModulation (filter, defaultParams, DelugeTag.LPF_FREQUENCY); + return filter; } } @@ -586,7 +597,9 @@ private static IFilter readFilter (final Element soundElement, final Element def { final String hpfResonance = nestedHpf != null ? getValue (nestedHpf, DelugeTag.RESONANCE) : getValue (defaultParams, DelugeTag.HPF_RESONANCE); final double resonance = DelugeValues.paramToLevel (DelugeValues.parseValue (hpfResonance, DelugeValues.PARAM_MIN)); - return createFilter (getValue (soundElement, DelugeTag.HPF_MODE), FilterType.HIGH_PASS, 2, DelugeValues.paramToCutoff (frequencyParam), resonance); + final IFilter filter = createFilter (getValue (soundElement, DelugeTag.HPF_MODE), FilterType.HIGH_PASS, 2, DelugeValues.paramToCutoff (frequencyParam), resonance); + applyFilterModulation (filter, defaultParams, DelugeTag.HPF_FREQUENCY); + return filter; } } @@ -594,6 +607,71 @@ private static IFilter readFilter (final Element soundElement, final Element def } + /** + * Apply the filter cutoff modulations which the Deluge stores as patch cables routed to the + * filter frequency: keyboard tracking (note source), the modulation envelope + * (envelope2) and velocity sensitivity (velocity source). + * + * @param filter The filter to fill + * @param defaultParams The default parameters element which contains the patch cables and the + * modulation envelope + * @param frequencyDestination The filter frequency modulation destination (lpfFrequency or + * hpfFrequency) + */ + private static void applyFilterModulation (final IFilter filter, final Element defaultParams, final String frequencyDestination) + { + // Keyboard tracking: the "note" source routed to the filter frequency. + filter.setCutoffKeyTracking (readPatchCableDepth (defaultParams, DelugeTag.SOURCE_NOTE, frequencyDestination)); + + // Filter envelope: the modulation envelope (envelope2) routed to the filter frequency. + final Element envelope2 = getDirectChild (defaultParams, DelugeTag.ENVELOPE2); + final IEnvelopeModulator envelopeModulator = filter.getCutoffEnvelopeModulator (); + if (envelope2 != null) + readEnvelope (envelope2, envelopeModulator.getSource ()); + envelopeModulator.setDepth (readPatchCableDepth (defaultParams, DelugeTag.ENVELOPE2, frequencyDestination)); + + // Filter velocity: the "velocity" source routed to the filter frequency. + filter.getCutoffVelocityModulator ().setDepth (readPatchCableDepth (defaultParams, DelugeTag.SOURCE_VELOCITY, frequencyDestination)); + } + + + /** + * Read the normalized depth of the first patch cable with the given source and destination. + * + * @param defaultParams The default parameters element which contains the patch cables + * @param source The modulation source to match + * @param destination The modulation destination to match + * @return The modulation depth in the range of [-1..1], 0 if there is no such patch cable + */ + private static double readPatchCableDepth (final Element defaultParams, final String source, final String destination) + { + final String amount = findPatchCableAmount (defaultParams, source, destination); + if (amount.isBlank ()) + return 0; + return DelugeValues.patchAmountToModulationDepth (DelugeValues.parseValue (amount, 0)); + } + + + /** + * Find the amount of the first patch cable with the given source and destination. + * + * @param defaultParams The default parameters element which contains the patch cables + * @param source The modulation source to match + * @param destination The modulation destination to match + * @return The amount value or an empty string if there is no matching patch cable + */ + private static String findPatchCableAmount (final Element defaultParams, final String source, final String destination) + { + final Element patchCables = getDirectChild (defaultParams, DelugeTag.PATCH_CABLES); + if (patchCables == null) + return ""; + for (final Element cable: getDirectChildren (patchCables, DelugeTag.PATCH_CABLE)) + if (source.equals (getValue (cable, DelugeTag.SOURCE)) && destination.equals (getValue (cable, DelugeTag.DESTINATION))) + return getValue (cable, DelugeTag.AMOUNT); + return ""; + } + + /** * Create a filter, mapping the Deluge filter mode to the type and number of poles. * diff --git a/src/main/java/de/mossgrabers/convertwithmoss/format/synthstrom/DelugeTag.java b/src/main/java/de/mossgrabers/convertwithmoss/format/synthstrom/DelugeTag.java index 52873392..ed8b4599 100644 --- a/src/main/java/de/mossgrabers/convertwithmoss/format/synthstrom/DelugeTag.java +++ b/src/main/java/de/mossgrabers/convertwithmoss/format/synthstrom/DelugeTag.java @@ -160,6 +160,8 @@ public class DelugeTag public static final String AMOUNT = "amount"; /** The patch source velocity. */ public static final String SOURCE_VELOCITY = "velocity"; + /** The patch source note (used for filter keyboard tracking). */ + public static final String SOURCE_NOTE = "note"; // Kit tags / attributes /** The container of all drum sounds of a kit. */ diff --git a/src/main/java/de/mossgrabers/convertwithmoss/format/synthstrom/DelugeValues.java b/src/main/java/de/mossgrabers/convertwithmoss/format/synthstrom/DelugeValues.java index 673771a8..99603d9d 100644 --- a/src/main/java/de/mossgrabers/convertwithmoss/format/synthstrom/DelugeValues.java +++ b/src/main/java/de/mossgrabers/convertwithmoss/format/synthstrom/DelugeValues.java @@ -391,6 +391,23 @@ public static double paramToCutoff (final int param) } + /** + * Convert a Deluge patch-cable amount (e.g. the note to lpfFrequency modulation + * used for filter keyboard tracking, or envelope2/velocity to a filter/amplitude + * destination) into a normalized modulation depth in the range of [-1..1]. A patch amount of + * {@link #PATCH_CABLE_FULL} (a fully open modulation) is mapped to full positive depth (+1, i.e. + * +100%). This is an approximation of the Deluge's modulation depth; the reference amount is the + * single place to calibrate it. + * + * @param amount The patch-cable amount + * @return The modulation depth in the range of [-1..1] + */ + public static double patchAmountToModulationDepth (final int amount) + { + return Math.clamp (amount / (double) PATCH_CABLE_FULL, -1.0, 1.0); + } + + /** * Calculate the root note of a sample from the Deluge transpose and cents values. * diff --git a/src/main/java/de/mossgrabers/convertwithmoss/format/waldorf/qpat/WaldorfQpatCreator.java b/src/main/java/de/mossgrabers/convertwithmoss/format/waldorf/qpat/WaldorfQpatCreator.java index 805271af..7dc35a8e 100644 --- a/src/main/java/de/mossgrabers/convertwithmoss/format/waldorf/qpat/WaldorfQpatCreator.java +++ b/src/main/java/de/mossgrabers/convertwithmoss/format/waldorf/qpat/WaldorfQpatCreator.java @@ -456,6 +456,14 @@ private static void createFilterParameters (final List par final double filterEnvAmount = modulator.getDepth (); parameters.add (new WaldorfQpatParameter ("Filter1EnvAmount", String.format (Locale.US, "%.2f", Double.valueOf (filterEnvAmount * 100.0)) + " %", (float) ((filterEnvAmount + 1.0) / 2.0))); + // Filter1Keytrack / Filter2Keytrack: [0.00] "-100.00 %" ... [0.50] "0.00 %" ... [1.00] + // "+100.00 %". Both filters track so the patch stays bright across the keyboard. + final double keyTracking = filter.getCutoffKeyTracking (); + final float keyTrackingValue = (float) Math.clamp ((keyTracking + 1.0) / 2.0, 0, 1); + final String keyTrackingText = String.format (Locale.US, "%.2f", Double.valueOf (keyTracking * 100.0)) + " %"; + parameters.add (new WaldorfQpatParameter ("Filter1Keytrack", keyTrackingText, keyTrackingValue)); + parameters.add (new WaldorfQpatParameter ("Filter2Keytrack", keyTrackingText, keyTrackingValue)); + final IEnvelope envelope = modulator.getSource (); createEnvelope (parameters, envelope, "Filter1Env", "Filter1", false);