From aec7e2486d416247a04c21cfcc3f58f49cb79aa6 Mon Sep 17 00:00:00 2001 From: Douglas Carmichael Date: Mon, 14 Sep 2026 15:36:15 -0400 Subject: [PATCH 1/2] Waldorf Quantum/Iridium: write up to four layers, the layout of the MK2 generation The 'Use a second layer' check box becomes a 'Maximum number of layers' drop-down with 1, 2, 3 or 4 layers. With 3 or 4, a source with more than 6 groups is written with up to 9 or 12 multi-samples in the layout the Iridium MK2 stores its own patches in: the layer count 2, the format version 15 and always four stored layers, of which the unused ones are switched off (LayerActive 0, no oscillators, no maps). The loader of the device clears the file offsets of the layers 3 and 4 in files up to the version 14, which is why the layout needs the version 15. Only as many layers as the groups need are used, so a source with up to 6 groups is still written as the 2-layer patch of the version 14 which every instrument of the family plays. The samples of all later layers are named in the first layer for the importer of the device. A setting of the old option is taken over, and the old command line parameter is still accepted. Verified on an Iridium MK2 with OS 4.0.6: a written 4-layer patch with one sample pitch per layer plays all four pitches, a 3-layer patch with its switched-off fourth layer plays its three. Claude-Session: https://claude.ai/code/session_01P2bmFi6STyJoKHvjkuLiVy --- documentation/CHANGELOG.md | 1 + documentation/README-FORMATS.md | 4 +- documentation/design/WALDORF_QPAT_FORMAT.md | 22 +++-- .../waldorf/qpat/WaldorfQpatCreator.java | 83 +++++++++++++---- .../waldorf/qpat/WaldorfQpatCreatorUI.java | 92 +++++++++++++++++-- src/main/resources/Strings.properties | 8 +- 6 files changed, 175 insertions(+), 35 deletions(-) diff --git a/documentation/CHANGELOG.md b/documentation/CHANGELOG.md index 8a01c449..2173fc9c 100644 --- a/documentation/CHANGELOG.md +++ b/documentation/CHANGELOG.md @@ -119,6 +119,7 @@ * Fixed: The cutoff of the filter was written with the normalized parameter value as its display text, so the device listed a patch with a cutoff of e.g. 61.7 Hz as '0.2592 Hz'. A cutoff at the top of the range of the model (20,000 Hz) also wrote a parameter value slightly above the 1.0 the device expects; it is now limited to the 19,912.2 Hz of the device. * New: The volume and the pan of the whole timbre (TBPIVolume, an attenuation in dB, and TBPIPan, -63..63) are read and applied on top of the settings of the partials; a timbre which the editor turned down - by up to 16 dB in the Studio G Dallas library - was converted at full level. * Waldorf Quantum/Iridium + * New: The 'Use a second layer' option became 'Maximum number of layers' with 1 to 4 layers. With 3 or 4 layers a source with more than 6 groups is written with up to 9 or 12 multi-samples in the layout of the MK2 generation: the layer count 2, the format version 15 and always four stored layers, of which the unused ones are switched off, exactly as the Iridium MK2 stores its own patches (304 files of the corpus). The loader of the device clears the file offsets of the layers 3 and 4 in files up to the version 14, which is why four layers need the version 15; the first generation of the instruments holds two layers (its manuals) and cannot play such a file. Verified on an Iridium MK2 (OS 4.0.6): a written 4-layer patch with one sample pitch per layer plays all four pitches, a 3-layer patch with its switched-off fourth layer plays its three. Only as many layers as the groups need are used, so a source with up to 6 groups is still written as the 2-layer patch which every instrument of the family plays. The samples of all later layers are named in the first layer for the importer of the device. A setting of the old option is taken over. * New: Round robins are written into one sample map. Entries of a map which overlap in key and velocity alternate on successive notes on the device, which is exactly a round robin; the zones of a round robin were spread over separate oscillators as if they were stacked layers, so all of them sounded at once on every note. When reading, overlapping entries of a map become a round robin. The device has no random selection, so random zones alternate as well. * New: A monophonic source is written with the mono mode of the patch (PolyMonoMode), which is read back as well. * New: The key window of an oscillator (Osc{i}MinNote/MaxNote) is applied when reading: a zone outside of it is silent on the device and is dropped, a zone across its edge is cut at it. diff --git a/documentation/README-FORMATS.md b/documentation/README-FORMATS.md index deff8ef1..dd1aee34 100644 --- a/documentation/README-FORMATS.md +++ b/documentation/README-FORMATS.md @@ -1015,7 +1015,7 @@ Writing creates such a folder, which only needs to be copied to the device. Note This family of Waldorf synthesizers supports the playback of multi-samples. One preset can contain several layers - 2 on the first generation of the instruments and 4 on the MK2 generation (Iridium MK2, Quantum MK2 and instruments upgraded to MK2 hardware). A layer is a complete preset in itself and the layers are simply concatenated. Each layer can have up to 3 oscillators of which each oscillator can contain its own multi-sample. The format is not documented by Waldorf; the file layout, the sample map and the value laws which are read and written are described in *documentation/design/WALDORF_QPAT_FORMAT.md*. -If this format is used as the source it produces one output preset per layer which plays samples; a layer which is switched off, or which plays a wave-table instead of samples, is reported and skipped. If used as the destination format, each group of the source multi-sample is applied to one of the 3 oscillators of a layer; with the *Use a second layer* option a preset holds up to 6 multi-samples instead of 3, and all groups which do not fit are added to the multi-sample of the last oscillator, as before. Note that the device cannot select a layer by velocity - it splits its layers by key, by MIDI channel or cycles them - so a velocity split is always written into the sample maps of one layer and only sounds which are meant to be heard together are spread across the layers. A round robin stays inside one sample map as well: entries of a map which overlap in key and velocity alternate on successive notes on the device, which is how it plays a round robin, and overlapping entries are read back as one (the device has no random selection, so random zones alternate too). A monophonic source is written with the mono mode of the patch. +If this format is used as the source it produces one output preset per layer which plays samples; a layer which is switched off, or which plays a wave-table instead of samples, is reported and skipped. If used as the destination format, each group of the source multi-sample is applied to one of the 3 oscillators of a layer; with the *Layers* option a preset holds up to 6 multi-samples on 2 layers or up to 9 or 12 on 3 or 4 layers of the MK2 generation instead of 3, and all groups which do not fit are added to the multi-sample of the last oscillator, as before. Note that the device cannot select a layer by velocity - it splits its layers by key, by MIDI channel or cycles them - so a velocity split is always written into the sample maps of one layer and only sounds which are meant to be heard together are spread across the layers. A round robin stays inside one sample map as well: entries of a map which overlap in key and velocity alternate on successive notes on the device, which is how it plays a round robin, and overlapping entries are read back as one (the device has no random selection, so random zones alternate too). A monophonic source is written with the mono mode of the patch. The volume, panning and tuning of an oscillator are offsets on top of the values of its sample map entries, therefore the two are combined when reading instead of the oscillator replacing the sample map. When writing, the volume and panning of a group are stored on its oscillator and only the remainder in its sample map, so they survive a Quantum/Iridium round-trip - previously the oscillator was always written as 0 dB and Center. The volume, gain and panning of a layer are applied on top of its oscillators when reading. The importer of the device copies only the samples which the first layer names, so the samples of a second layer are named in a map of the first layer as well, in entries which never play. @@ -1029,7 +1029,7 @@ Since the device has a field of its own for the bank, a source which carries its * Author: Written into the preset's Author field, which the device shows and can group presets by. When left empty, the creator from the source metadata is kept (e.g. the sound designer stored in a SoundFont). * Bank: Written into the preset's Bank field. When left empty, the description from the source metadata is kept. Both the Author and the Bank only take as much text as the device shows completely: its *Load Patch* page shows the name, the bank and the author of the selected preset in a narrow column and leaves out every character which does not fit - e.g. of the bank 'Digital Collection Vintage' only 'Digital Collection Vinta' is visible. The font is proportional, so how many characters fit depends on the characters: on average 17 capital letters or 20 lower-case ones, but e.g. 11 of the letter 'm'. On the command line (QPATAuthor / QPATBank) a value which is too wide is reported, together with the part which the device would show, and nothing is converted. * Prefix file names with an import number: Prefixes each written preset file with a 5-digit number (e.g. *05002-Name.qpat*), which mirrors the naming of the device's own preset export; on import the device assigns the preset to that number. The *First import number* is used for the first written preset and each further preset increases the number by one; the source presets are converted in alphabetical order. -* Use a second layer for more than 3 groups: A preset normally holds one layer with up to 3 multi-samples, one on each oscillator, and every further group of the source is merged into the third of them. With this option a source with more than 3 groups is written as 2 layers with up to 6 multi-samples, which keeps the groups apart and gives the second set of them its own filter and amplitude envelope. Two layers are stored the same way by every instrument of the family, so such a preset plays on a Quantum, an Iridium and an Iridium Core as well as on the MK2 generation. Only groups which sound at the same time are put into a separate layer - the device cannot select a layer by velocity, so a velocity split stays inside the sample maps. Disabled by default. Verified on an Iridium MK2: the written preset loads and its second layer carries its own filter and amplitude envelope. The device imports only the samples which the first layer names, so the samples of the second layer are named in the last sample map of the first layer as well, in entries which never play. Verified on an Iridium MK2 (OS 4.0.6): a test preset whose second layer brings a sample of its own plays both layers after the import, while the same preset without the entries fails to load that sample. +* Maximum number of layers: A preset normally holds one layer with up to 3 multi-samples, one on each oscillator, and every further group of the source is merged into the third of them. With 2 layers a source with more than 3 groups is written as 2 layers with up to 6 multi-samples, which keeps the groups apart and gives the second set of them its own filter and amplitude envelope; with 3 or 4 layers a source with more than 6 groups is written with up to 9 or 12 multi-samples - 3 leaves the fourth layer of an MK2 free for a sound of your own. Only as many layers as the groups need are used. Two layers are stored the same way by every instrument of the family, so such a preset plays on a Quantum, an Iridium and an Iridium Core as well as on the MK2 generation; a preset with 3 or 4 layers uses the layout of the MK2 generation (format version 15, four stored layers of which the unused ones are switched off), which only the Iridium MK2, the Quantum MK2 and first-generation instruments upgraded with the MK2 hardware can play - the first generation holds two layers. Only groups which sound at the same time are put into a separate layer - the device cannot select a layer by velocity, so a velocity split stays inside the sample maps. The default is 1 layer. Verified on an Iridium MK2: a written 2-layer preset loads and its second layer carries its own filter and amplitude envelope. The device imports only the samples which the first layer names, so the samples of the later layers are named in the last sample map of the first layer as well, in entries which never play - verified on an Iridium MK2 (OS 4.0.6): a test preset whose second layer brings a sample of its own plays both layers after the import, while the same preset without the entries fails to load that sample. Verified on an Iridium MK2 (OS 4.0.6) as well: a written 4-layer preset with one sample pitch per layer plays all four pitches, and a 3-layer preset with its switched-off fourth layer plays its three. * Shorten file names to the displayed preset name: Names the written file after the preset name which the device displays instead of the full name of the source. A preset of a bank otherwise carries its bank in the file name (e.g. *05000-Full Arco String - Arco Strings Lo.qpat*, 45 characters), which is more than the import screen of the device shows - measured on an Iridium MK2, its file list displays about 43 characters and cuts off the rest, exactly the end that tells the presets apart. With this option the example becomes *05000-Arco Strings Lo.qpat*, and the whole file name - including the import number prefix and the file ending - is kept within 40 characters. Disabled by default, since the full name is what tells the presets of different banks apart in a file browser on the computer. * Options to write/update [WAV Chunk Information](#wav-chunk-information) diff --git a/documentation/design/WALDORF_QPAT_FORMAT.md b/documentation/design/WALDORF_QPAT_FORMAT.md index e96179dd..9dde25aa 100644 --- a/documentation/design/WALDORF_QPAT_FORMAT.md +++ b/documentation/design/WALDORF_QPAT_FORMAT.md @@ -750,7 +750,12 @@ brings a sample of its own plays both layers after the import, while the same pa entry fails with *loading samples/.../L2_saw_octave.wav failed*. A written two-layer patch loads and its second layer carries its own filter and amplitude envelope -**(hw)**; the samples of the second layer were the open problem described above. +**(hw)**; the samples of the second layer were the open problem described above. ConvertWithMoss +writes the four-layer layout of the MK2 - count 2, version 15, four stored layers, the unused ones +with `LayerActive` 0 and without oscillators - when a source needs three or four layers. Verified +on an Iridium MK2 with OS 4.0.6 **(hw)**: a written four-layer patch with one sample pitch per +layer plays all four pitches after the import, and a three-layer patch with its switched-off +fourth layer plays its three. ## 8. Reading a patch @@ -816,7 +821,12 @@ A written two-layer patch loads and its second layer carries its own filter and whether an MK2 can write code 1, and whether a first-generation instrument accepts code 2, is unknown. The loader accepts the codes 1, 2 and 3 as "more than one layer" and loads the layers 3 and 4 for the codes 2 and 3 (fw). A written two-layer patch loads and, with the entries of - section 7.5, plays its own samples in the second layer (hw). + section 7.5, plays its own samples in the second layer; a written four-layer patch (count 2, + version 15) plays all four layers on an Iridium MK2 (hw). The first generation holds two layers - + the Iridium and Iridium Core manuals switch "between the two layers by tapping on the Layer 1 or + Layer 2 button", and Waldorf describes the MK2 upgrade as raising the layer count from 2 to 4 - + so a count-2 file is for the MK2 generation only; how a first-generation OS reacts to one is not + known (manual). * **Oscillator type indices.** *Off* is 5 in versions 9-11 and 6 in version 15; how an instrument interprets the index of a file of another version (e.g. a version-14 file with `Osc2Type` = 5 on an MK2) is untested - hence the advice to write nothing for unused oscillators. @@ -870,7 +880,7 @@ For readers who want to compare an implementation against ConvertWithMoss (`Wald Experimental*). * **Options**: re-sample to 16 bit / 44.1 kHz (default on), *Author* and *Bank* overrides, the `NNNNN-` import number prefix with a configurable first number, short file names which keep the - whole file name within 40 characters, and *Use a second layer for more than 3 groups* (off by - default, which writes exactly what earlier versions wrote). It writes at most two layers - the - layer count 1, which every instrument of the family has stored since the format version 8 - and - never the four-layer count, which has only been seen in files of the version 15. + whole file name within 40 characters, and *Maximum number of layers* with 1 to 4 layers (1 by default, + which writes exactly what earlier versions wrote). One or two layers use the layer count 1, which + every instrument of the family has stored since the format version 8; three or four use the + four-layer layout of section 7.5 with the format version 15. 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 e98f0cbf..844c230a 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 @@ -80,7 +80,14 @@ public class WaldorfQpatCreator extends AbstractWavCreator /** The sample rate which the device plays and to which this creator re-samples. */ private static final int DESTINATION_SAMPLE_RATE = 44100; + /** The format version of a patch with one or two layers, which every instrument of the family loads. */ private static final int PRESET_VERSION = 14; + /** + * The format version of a patch with the four-layer layout. The loader clears the file offsets + * of the layers 3 and 4 in files up to the version 14, so four layers need the version of the + * MK2 generation. + */ + private static final int PRESET_VERSION_FOUR = 15; /** The size of the header of a patch, which every layer of a patch has as well. */ private static final int HEADER_SIZE = 512; @@ -99,14 +106,19 @@ public class WaldorfQpatCreator extends AbstractWavCreator */ private static final int MAX_MAP_ENTRIES = 1024; /** - * The maximum number of layers which is written. The MK2 generation of the instruments stores - * four layers, but that layer count has only ever been observed in files of the format version - * 15, while two layers are stored the same way from the version 8 on - so a patch with two - * layers plays on every instrument of the family. + * The maximum number of layers which is written. Two layers are stored the same way from the + * format version 8 on, so a patch with two layers plays on every instrument of the family. Four + * layers are the layout of the MK2 generation (Iridium MK2, Quantum MK2 and first-generation + * instruments upgraded to the MK2 hardware): the layer count 2, the format version 15 and + * always four stored layers, of which the unused ones are switched off. */ - private static final int MAX_LAYERS = 2; + private static final int MAX_LAYERS = 4; + /** The number of layers which a patch with three or four layers stores. */ + private static final int MAX_LAYERS_TWO = 2; /** Layer count: two layers, the file offset of the 2nd one is stored at 432. */ private static final int LAYER_COUNT_TWO = 1; + /** Layer count: four layers, the file offsets of the layers 2, 3 and 4 are stored at 432, 440 and 444. */ + private static final int LAYER_COUNT_FOUR = 2; /** The header holds the file offsets of the layers 2, 3 and 4. */ private static final int NUM_LAYER_OFFSETS = 3; /** @@ -407,15 +419,33 @@ private void storeMultisample (final IMultisampleSource multisampleSource, final metadata.setDescription (bank); final int numLayers = layers.size (); - final int layerCount = numLayers == 1 ? 0 : LAYER_COUNT_TWO; + // One or two layers are stored the way every instrument of the family stores them; three + // or four layers need the layout of the MK2 generation, which always stores four layers - + // the loader checks the offsets of all of them - and switches the unused ones off + final boolean fourLayerLayout = numLayers > MAX_LAYERS_TWO; + final int layerCount = numLayers == 1 ? 0 : fourLayerLayout ? LAYER_COUNT_FOUR : LAYER_COUNT_TWO; + final int version = fourLayerLayout ? PRESET_VERSION_FOUR : PRESET_VERSION; + final int numStoredLayers = fourLayerLayout ? MAX_LAYERS : numLayers; + if (fourLayerLayout) + this.notifier.log ("IDS_QPAT_NOTIFY_FOUR_LAYERS", Integer.toString (numLayers)); // The content of every layer has to be known before the first one can be written, since // the header holds the file offset of the following layer final List> layerParameters = new ArrayList<> (); final List> layerSampleMaps = new ArrayList<> (); - final int [] layerSizes = new int [numLayers]; - for (int i = 0; i < numLayers; i++) + final int [] layerSizes = new int [numStoredLayers]; + for (int i = 0; i < numStoredLayers; i++) { + if (i >= numLayers) + { + // A stored layer which is not used: switched off, without oscillators and maps + final List parameters = createInactiveLayerParameters (); + layerParameters.add (parameters); + layerSampleMaps.add (new ArrayList<> ()); + layerSizes[i] = HEADER_SIZE + parameters.size () * PARAMETER_SIZE; + continue; + } + final List groups = layers.get (i); // A zero-attack/zero-decay amplitude envelope that sustains below full level makes the // device pop at the start of each note: it snaps to the 100% attack peak and then @@ -438,18 +468,37 @@ private void storeMultisample (final IMultisampleSource multisampleSource, final layerSizes[i] = size; } - // The absolute file offsets of the layers 2, 3 and 4; a layer which is not stored keeps 0 + // The absolute file offsets of the layers 2, 3 and 4, each the sum of the sizes of the + // layers in front of it; a layer which is not stored keeps 0 final int [] layerOffsets = new int [NUM_LAYER_OFFSETS]; - System.arraycopy (layerSizes, 0, layerOffsets, 0, numLayers - 1); + for (int i = 1; i < numStoredLayers; i++) + layerOffsets[i - 1] = layerOffsets[Math.max (0, i - 2)] + layerSizes[i - 1]; try (final FileOutputStream out = new FileOutputStream (multiFile)) { - for (int i = 0; i < numLayers; i++) - writeLayer (out, metadata, deviceName, layerParameters.get (i), layerSampleMaps.get (i), layerCount, numLayers == 1 ? 0 : (int) TIMBRE_MODE_MULTI, layerOffsets); + for (int i = 0; i < numStoredLayers; i++) + writeLayer (out, metadata, deviceName, version, layerParameters.get (i), layerSampleMaps.get (i), layerCount, numLayers == 1 ? 0 : (int) TIMBRE_MODE_MULTI, layerOffsets); } } + /** + * Create the parameters of a stored layer which is not used. The four-layer layout always + * stores four layers, so a patch with three sounding layers stores a fourth one which is + * switched off; its oscillators are left unwritten, which the device reads as Off. + * + * @return The parameters + */ + private static List createInactiveLayerParameters () + { + final List parameters = new ArrayList<> (); + parameters.add (new WaldorfQpatParameter ("TimbreMode", "Layered", TIMBRE_MODE_MULTI)); + parameters.add (new WaldorfQpatParameter ("MultiAllocMode", "Layered", 0)); + parameters.add (new WaldorfQpatParameter ("LayerActive", "Off", 0)); + return parameters; + } + + /** * Write one layer of the patch: its header, its parameters and its sample maps. A layer is a * complete patch of its own; the layers of a patch are simply stored one after the other. @@ -457,6 +506,7 @@ private void storeMultisample (final IMultisampleSource multisampleSource, final * @param out The output stream to write to * @param metadata The metadata of the multi-sample * @param deviceName The name to write into the name field, which the device displays + * @param version The format version of the patch * @param parameters The parameters of the layer * @param sampleMaps The sample maps of the layer * @param layerCount The number of layers of the patch, coded as the device does @@ -464,9 +514,9 @@ private void storeMultisample (final IMultisampleSource multisampleSource, final * @param layerOffsets The absolute file offsets of the layers 2, 3 and 4 * @throws IOException Could not write the layer */ - private static void writeLayer (final OutputStream out, final IMetadata metadata, final String deviceName, final List parameters, final List sampleMaps, final int layerCount, final int timbreMode, final int [] layerOffsets) throws IOException + private static void writeLayer (final OutputStream out, final IMetadata metadata, final String deviceName, final int version, final List parameters, final List sampleMaps, final int layerCount, final int timbreMode, final int [] layerOffsets) throws IOException { - writeHeader (out, metadata, deviceName); + writeHeader (out, metadata, deviceName, version); StreamUtils.writeUnsigned16 (out, parameters.size (), false); StreamUtils.padBytes (out, 2); @@ -1466,12 +1516,13 @@ else if (releaseSlope < 0) * @param out The output stream to write to * @param metadata The metadata * @param name The name of the multi-sample + * @param version The format version of the patch * @throws IOException Could not write */ - private static void writeHeader (final OutputStream out, final IMetadata metadata, final String name) throws IOException + private static void writeHeader (final OutputStream out, final IMetadata metadata, final String name, final int version) throws IOException { StreamUtils.writeUnsigned32 (out, WaldorfQpatConstants.MAGIC, false); - StreamUtils.writeUnsigned32 (out, PRESET_VERSION, false); + StreamUtils.writeUnsigned32 (out, version, false); StreamUtils.writeAscii (out, StringUtils.fixASCII (name), WaldorfQpatConstants.MAX_STRING_LENGTH); // The author (offset 40) and bank (offset 72) fields are shown by the device. Use the // explicit creator settings when provided, otherwise fall back to the source metadata. diff --git a/src/main/java/de/mossgrabers/convertwithmoss/format/waldorf/qpat/WaldorfQpatCreatorUI.java b/src/main/java/de/mossgrabers/convertwithmoss/format/waldorf/qpat/WaldorfQpatCreatorUI.java index a6d6cea2..ec920d7f 100644 --- a/src/main/java/de/mossgrabers/convertwithmoss/format/waldorf/qpat/WaldorfQpatCreatorUI.java +++ b/src/main/java/de/mossgrabers/convertwithmoss/format/waldorf/qpat/WaldorfQpatCreatorUI.java @@ -18,6 +18,7 @@ import de.mossgrabers.tools.ui.panel.BoxPanel; import javafx.geometry.Orientation; import javafx.scene.control.CheckBox; +import javafx.scene.control.ComboBox; import javafx.scene.control.TextField; import javafx.scene.control.TextFormatter; import javafx.scene.control.Tooltip; @@ -37,7 +38,17 @@ public class WaldorfQpatCreatorUI extends WavChunkSettingsUI private static final String QPAT_NUMBER_PREFIX = "QPATNumberPrefix"; private static final String QPAT_SHORT_FILE_NAMES = "QPATShortFileNames"; private static final String QPAT_NUMBER_PREFIX_START = "QPATNumberPrefixStart"; + private static final String QPAT_LAYERS = "QPATLayers"; + /** The option which the layers option replaced, still read so that its setting is taken over. */ private static final String QPAT_SECOND_LAYER = "QPATUseSecondLayer"; + /** The choices of the layers option: the maximum number of layers of a patch. */ + private static final int [] LAYER_OPTIONS = + { + 1, + 2, + 3, + 4 + }; private CheckBox limitTo16441CheckBox; private TextField authorField; @@ -45,14 +56,14 @@ public class WaldorfQpatCreatorUI extends WavChunkSettingsUI private CheckBox numberPrefixCheckBox; private TextField numberPrefixStartField; private CheckBox shortFileNamesCheckBox; - private CheckBox secondLayerCheckBox; + private ComboBox layersBox; private boolean limitTo16441; private String author = ""; private String bank = ""; private boolean numberPrefix; private int numberPrefixStart = 0; private boolean shortFileNames; - private boolean useSecondLayer; + private int maximumLayers = 1; /** @@ -82,7 +93,7 @@ public Pane getEditPane () this.numberPrefixStartField = panel.createPositiveIntegerField ("@IDS_QPAT_NUMBER_PREFIX_START"); this.numberPrefixStartField.disableProperty ().bind (this.numberPrefixCheckBox.selectedProperty ().not ()); this.shortFileNamesCheckBox = panel.createCheckBox ("@IDS_QPAT_SHORT_FILE_NAMES"); - this.secondLayerCheckBox = panel.createCheckBox ("@IDS_QPAT_SECOND_LAYER"); + this.layersBox = panel.createComboBox ("@IDS_QPAT_LAYERS", List.of (Functions.getText ("@IDS_QPAT_LAYERS_ONE"), Functions.getText ("@IDS_QPAT_LAYERS_TWO"), Functions.getText ("@IDS_QPAT_LAYERS_THREE"), Functions.getText ("@IDS_QPAT_LAYERS_FOUR"))); final TitledSeparator separator = this.addWavChunkOptions (panel); separator.getStyleClass ().add ("titled-separator-pane"); @@ -100,7 +111,9 @@ public void loadSettings (final BasicConfig config) this.numberPrefixCheckBox.setSelected (config.getBoolean (QPAT_NUMBER_PREFIX, false)); this.numberPrefixStartField.setText (Integer.toString (config.getInteger (QPAT_NUMBER_PREFIX_START, 0))); this.shortFileNamesCheckBox.setSelected (config.getBoolean (QPAT_SHORT_FILE_NAMES, false)); - this.secondLayerCheckBox.setSelected (config.getBoolean (QPAT_SECOND_LAYER, false)); + // The setting of the option which this one replaced is taken over + final int layers = config.getInteger (QPAT_LAYERS, config.getBoolean (QPAT_SECOND_LAYER, false) ? 2 : 1); + this.layersBox.getSelectionModel ().select (layersToIndex (layers)); super.loadSettings (config); } @@ -116,7 +129,7 @@ public void saveSettings (final BasicConfig config) config.setBoolean (QPAT_NUMBER_PREFIX, this.numberPrefixCheckBox.isSelected ()); config.setInteger (QPAT_NUMBER_PREFIX_START, this.parseNumberPrefixStart ()); config.setBoolean (QPAT_SHORT_FILE_NAMES, this.shortFileNamesCheckBox.isSelected ()); - config.setBoolean (QPAT_SECOND_LAYER, this.secondLayerCheckBox.isSelected ()); + config.setInteger (QPAT_LAYERS, LAYER_OPTIONS[selectedLayerIndex (this.layersBox)]); super.saveSettings (config); } @@ -135,7 +148,7 @@ public boolean checkSettingsUI (final INotifier notifier) this.numberPrefix = this.numberPrefixCheckBox.isSelected (); this.numberPrefixStart = this.parseNumberPrefixStart (); this.shortFileNames = this.shortFileNamesCheckBox.isSelected (); - this.useSecondLayer = this.secondLayerCheckBox.isSelected (); + this.maximumLayers = LAYER_OPTIONS[selectedLayerIndex (this.layersBox)]; return true; } @@ -174,7 +187,19 @@ public boolean checkSettingsCLI (final INotifier notifier, final Map box) + { + return Math.clamp (box.getSelectionModel ().getSelectedIndex (), 0, LAYER_OPTIONS.length - 1); + } + + + /** + * Get the index of the option which holds the given number of layers. + * + * @param layers The number of layers + * @return The index into LAYER_OPTIONS, the first one if the number is not an option + */ + private static int layersToIndex (final int layers) + { + for (int i = 0; i < LAYER_OPTIONS.length; i++) + if (LAYER_OPTIONS[i] == layers) + return i; + return 0; + } + + + /** + * Parse the value of the layers parameter. + * + * @param value The value + * @return The number of layers or -1 if the value is not one of the options + */ + private static int parseLayers (final String value) + { + try + { + final int layers = Integer.parseInt (value.trim ()); + return LAYER_OPTIONS[layersToIndex (layers)] == layers ? layers : -1; + } + catch (final NumberFormatException _) + { + return -1; + } + } } diff --git a/src/main/resources/Strings.properties b/src/main/resources/Strings.properties index 7fb356ca..b3fd4b52 100644 --- a/src/main/resources/Strings.properties +++ b/src/main/resources/Strings.properties @@ -516,6 +516,8 @@ IDS_QPAT_NOT_SAMPLE_BASED=Preset is not sample based.\n IDS_QPAT_LAYER_INACTIVE=Layer %1 is switched off and is therefore not converted.\n IDS_QPAT_LAYER_NOT_SAMPLE_BASED=Layer %1 is not sample based and is therefore not converted.\n IDS_QPAT_NOTIFY_LAYERS=The multi-sample is written as %1 layers with %2 sample maps.\n +IDS_QPAT_NOTIFY_FOUR_LAYERS=%1 layers need the four-layer layout of the MK2 generation (format version 15, four stored layers), which the first generation of the instruments with its two layers cannot play.\n +IDS_QPAT_CLI_LAYERS=The value of %1 must be 1, 2, 3 or 4.\n IDS_QPAT_NOTIFY_SHADOW_SAMPLES=%1 sample(s) which only a later layer plays are also named in the sample map of the first layer, since the device imports the samples of the first layer only.\n IDS_QPAT_NOTIFY_TOO_MANY_ENTRIES=Sample map %1 holds %2 entries, the device stores at most %3 and drops the rest.\n IDS_QPAT_OSC_SILENCED=Volume of oscillator %1 is set to -Infinity and might therefore not sound.\n @@ -924,7 +926,11 @@ IDS_QPAT_PATCH_INFO_TOOLTIP=Limited to what the device shows completely on its ' IDS_QPAT_NUMBER_PREFIX=Prefix file names with an import number IDS_QPAT_SHORT_FILE_NAMES=Shorten file names to the displayed preset name IDS_QPAT_NUMBER_PREFIX_START=First import number -IDS_QPAT_SECOND_LAYER=Use a second layer for more than 3 groups +IDS_QPAT_LAYERS=Maximum number of layers +IDS_QPAT_LAYERS_ONE=1 layer +IDS_QPAT_LAYERS_TWO=2 layers +IDS_QPAT_LAYERS_THREE=3 layers (MK2 only) +IDS_QPAT_LAYERS_FOUR=4 layers (MK2 only) IDS_SF2_NAMING=Naming of output files IDS_SF2_NAMING_ADD_FILE_NAME=Prefix with file name From 87dce42150f77b2da8e1536a6964888b24613df0 Mon Sep 17 00:00:00 2001 From: Douglas Carmichael Date: Mon, 14 Sep 2026 20:04:59 -0400 Subject: [PATCH 2/2] Waldorf Quantum/Iridium: apply the modulation and envelope laws of the firmware 4.0.6, convert the filter LFO, the glide and the polyphony The Iridium MK2 firmware 4.0.6 squares the amount of a matrix slot on the destinations Pitch and Osc1-3 Pitch, keeping its sign, and multiplies the sum with 24 semitones (the destinations are registered with the squaring flag, ModMatrix::calc squares the amount with fabs). Pitch envelopes and vibratos were written with the linear semitones / 24: an amount of 50 % plays 6 semitones and the amount written for a 50 cent vibrato plays about 1 cent. Both directions now use the law of the device; the modulation-wheel vibratos of the factory patches (11-20 %) are 29-96 cents with it. The index of a matrix destination depends on the format version (VCA 107 up to version 13, 108 in 14, 117 in 15) and the device resolves every enumeration by the name in its hint (PatchLib::migrateIOAreaToStagingArea calls Param::DiscreteNameToIndex). The reader matches destinations by the hint and falls back to the index of the version, which finds the tremolo of patches written by older firmware and no longer takes e.g. 'LFO1 Speed' at 117 of a version 8 file for one. The writer uses the index of the written version. New: an LFO on Filter1 Cutoff is read and written (slot 6, LFO 3; linear, 135 semitones at the full amount). A portamento is written as the glide and read back (2 x GlideRate^2 seconds for every interval, Legato type for a legato source). A polyphony of 1 is written with the mono mode and a mono patch is read with a polyphony of 1. A key tracking which all key-tracked zones of a group share goes into the key tracking of the oscillator. Fixed: every attack, decay and release was 60 ms too short on the device. The sound engine plays a stage as 60 x 10^(3 (x - 1)) - 0.06 s; the display shows - 0.001 s, the law which was used, which also wrote every stage below 0.06 s as instant. Both directions now use the law of the engine. The de-click minimum becomes 0.01 s, which writes exactly the value of the former 0.07 s - the length the device played in the hardware test which established it. Fixed: a pitch envelope without a start level (-1, unset) was written like one which starts at a level - attack 0 and the source's attack time as its decay - so the decay of e.g. an SFZ pitch drop was lost. The hint of a negative pitch bend range was written as '--2'. Checked: the QPAT corpus harness 19 of 19; all 1,787 factory patches analyse without an exception; an SFZ with polyphony 1, 50 % key tracking, a vibrato, a pitch envelope, a cutoff LFO and a tremolo round-trips through QPAT to SFZ and QPAT unchanged; reading the factory and Flux Vol. 1 patches changes only the pitch amounts, adds 3 and 4 cutoff LFOs and moves one tremolo to the real VCA slot; before the envelope law change 1,146 presets of four other libraries (two SF2, a Renoise and an E4B set) were written byte-identically, since none of them uses these modulations; with it, PX Vol. 2 (43) and an E4B bank (474) differ only in envelope times (AmpEnvRelease in all, AmpEnvDecay in 85, filter envelope times in 18), their sample maps are identical. Hardware (Iridium MK2, OS 4.0.6, recordings 2026-09-14): a free envelope at full level into Osc2 Pitch at +50 % plays 6.00 semitones above the unmodulated oscillator at two keys (squared law); a GlideRate of 0.7071 glides for 1.000 s over 200 and 400 cents in a straight line of pitch; a unipolar square LFO at +8.89 % on Filter1 Cutoff moves a resonant 24 dB low-pass by 11.75 +/- 0.5 semitones, every 1.000 s; a converted 50 cent vibrato at 5 Hz swings +/-49.97 cents at 4.999 Hz, the daily driver's conversion of the same source +/-1.04 cents, as the squared law predicts for its linear amount; a converted SFZ pitch drop of 12 semitones over 0.5 s starts four notes 12.02 semitones high and slides down on a straight line (within 0.8 cents) in 0.408-0.437 s, while the daily driver's conversion plays no slide at all. Envelope times with EnvelopeVariation at 0 (three patches, three linear pitch envelopes each, three notes each): stages written for 0.07, 0.1, 0.25, 0.5, 1 and 2 s with the display law took 0.011/0.041/0.190/0.441/0.940/1.940 s for attack, decay and release, within 2 ms across notes - the engine law above. Claude-Session: https://claude.ai/code/session_01VCnGn5JX5qjoiFb6874GV4 --- documentation/CHANGELOG.md | 9 +- documentation/README-FORMATS.md | 4 +- documentation/design/WALDORF_QPAT_FORMAT.md | 159 ++++++++---- .../waldorf/qpat/WaldorfQpatCreator.java | 245 +++++++++++++----- .../waldorf/qpat/WaldorfQpatDetector.java | 195 +++++++++----- .../qpat/WaldorfQpatModulationMatrix.java | 205 +++++++++++++++ 6 files changed, 652 insertions(+), 165 deletions(-) create mode 100644 src/main/java/de/mossgrabers/convertwithmoss/format/waldorf/qpat/WaldorfQpatModulationMatrix.java diff --git a/documentation/CHANGELOG.md b/documentation/CHANGELOG.md index 2173fc9c..ae8c3d0b 100644 --- a/documentation/CHANGELOG.md +++ b/documentation/CHANGELOG.md @@ -121,7 +121,14 @@ * Waldorf Quantum/Iridium * New: The 'Use a second layer' option became 'Maximum number of layers' with 1 to 4 layers. With 3 or 4 layers a source with more than 6 groups is written with up to 9 or 12 multi-samples in the layout of the MK2 generation: the layer count 2, the format version 15 and always four stored layers, of which the unused ones are switched off, exactly as the Iridium MK2 stores its own patches (304 files of the corpus). The loader of the device clears the file offsets of the layers 3 and 4 in files up to the version 14, which is why four layers need the version 15; the first generation of the instruments holds two layers (its manuals) and cannot play such a file. Verified on an Iridium MK2 (OS 4.0.6): a written 4-layer patch with one sample pitch per layer plays all four pitches, a 3-layer patch with its switched-off fourth layer plays its three. Only as many layers as the groups need are used, so a source with up to 6 groups is still written as the 2-layer patch which every instrument of the family plays. The samples of all later layers are named in the first layer for the importer of the device. A setting of the old option is taken over. * New: Round robins are written into one sample map. Entries of a map which overlap in key and velocity alternate on successive notes on the device, which is exactly a round robin; the zones of a round robin were spread over separate oscillators as if they were stacked layers, so all of them sounded at once on every note. When reading, overlapping entries of a map become a round robin. The device has no random selection, so random zones alternate as well. - * New: A monophonic source is written with the mono mode of the patch (PolyMonoMode), which is read back as well. + * New: A monophonic source is written with the mono mode of the patch (PolyMonoMode), which is read back as well. A source with a polyphony of 1 is written with it too, and a patch in the mono mode is read with a polyphony of 1. + * New: The modulation of the filter cutoff by a low frequency oscillator is read and written, in the matrix slot 6 with LFO 3 on the destination Filter1 Cutoff. The device adds the amount to the cutoff without squaring it, and the full amount covers the whole range of the filter, 135 semitones (Iridium MK2 firmware 4.0.6). Verified on an Iridium MK2: +8.89 % moves the cutoff by an octave. + * New: The portamento of a source is written as the glide of the patch and read back. The device glides for 2 x GlideRate^2 seconds, the same time for every interval (Iridium MK2 firmware 4.0.6), which is what the portamento time of a source means; a legato source uses the glide type Legato. Verified on an Iridium MK2: a glide of 1 second takes 1.000 seconds over 2 and over 4 semitones. + * New: A key tracking other than 100 % which all key-tracked zones of a group share is written as the key tracking of their oscillator instead of 100 %. + * Fixed: Pitch envelopes and vibratos were written far too shallow and read far too deep. The device squares the amount of a matrix slot which modulates a pitch before it applies it, keeping its sign, and multiplies the result with 24 semitones (Iridium MK2 firmware 4.0.6): an amount of 50 % raises the pitch by 6 semitones, not by 12, and the amount which was written for a vibrato of 50 cents plays one of about 1 cent. The amounts are now written and read with the law of the device. The vibratos which the factory patches put on the modulation wheel, 11 to 20 %, are 29 to 96 cents with it. Verified on an Iridium MK2 (OS 4.0.6): an oscillator modulated at +50 % plays 6.00 semitones above an unmodulated one, and a converted vibrato of 50 cents swings +/-49.97 cents, where the previous conversion swings +/-1.04 cents. + * Fixed: Every attack, decay and release was played 60 ms shorter than the source asked for, and a stage shorter than 60 ms was written as instant. The sound engine plays the value x of such a stage as 60 x 10^(3 (x - 1)) - 0.06 seconds, while the display of the device shows 60 x 10^(3 (x - 1)) - 0.001 seconds - the law which was used. Measured on an Iridium MK2 with OS 4.0.6: stages written for 0.07, 0.1, 0.25, 0.5, 1 and 2 seconds took 0.011, 0.041, 0.190, 0.441, 0.940 and 1.940 seconds, alike for the attack, the decay and the release. The times are now written and read with the law of the sound engine. The shortest amplitude attack and release which avoid a click stay at the 10 ms which the device played before. + * Fixed: The decay of a pitch envelope was lost when the source did not set a start level. Such an envelope was written like one which starts at a level: no attack, and the attack time of the source as its decay. An SFZ pitch drop over 0.5 seconds arrived as an instant blip. Only an envelope with a start level above 0 is written that way now. Verified on an Iridium MK2: the converted pitch drop starts every note an octave high and slides down in a straight line, where the previous conversion plays the note at its pitch from the start. + * Fixed: The tremolo of a patch written by an older firmware was not found, and a slot which modulates another destination with the index 117 could be read as a tremolo. The list of matrix destinations grew with the firmware - the VCA is 107 up to the format version 13, 108 in version 14 and 117 in version 15 - and the device resolves a destination by the name in the hint of the parameter (Iridium MK2 firmware 4.0.6). Destinations are now read by their name, by the index of the format version if the hint is empty, and written with the index of the written version. * New: The key window of an oscillator (Osc{i}MinNote/MaxNote) is applied when reading: a zone outside of it is silent on the device and is dropped, a zone across its edge is cut at it. * New: The volume, the gain and the panning of a layer (LayerVolume, LayerGain, LayerPan) are applied to the zones of the layer when reading. A layer at a LayerVolume of 0.81 - the value of one of the factory patches - is 3.7 dB quieter than its oscillators say. The laws come from the firmware of the Iridium MK2: LayerVolume follows the law of the oscillator volume (40 x log10), LayerGain adds 0 to 24 dB. * New: A sample map with more than 1024 entries is reported. The device stores at most 1024 entries per map and drops every further one with the error 'Number of entries in sample map exceeded' (Iridium MK2 firmware 4.0.6). diff --git a/documentation/README-FORMATS.md b/documentation/README-FORMATS.md index dd1aee34..5ebfbaf6 100644 --- a/documentation/README-FORMATS.md +++ b/documentation/README-FORMATS.md @@ -1019,7 +1019,9 @@ If this format is used as the source it produces one output preset per layer whi The volume, panning and tuning of an oscillator are offsets on top of the values of its sample map entries, therefore the two are combined when reading instead of the oscillator replacing the sample map. When writing, the volume and panning of a group are stored on its oscillator and only the remainder in its sample map, so they survive a Quantum/Iridium round-trip - previously the oscillator was always written as 0 dB and Center. The volume, gain and panning of a layer are applied on top of its oscillators when reading. The importer of the device copies only the samples which the first layer names, so the samples of a second layer are named in a map of the first layer as well, in entries which never play. -A vibrato and a tremolo are carried through the modulation matrix. Of its 40 slots the first three are taken by the pitch envelopes of the three oscillators, so the vibrato uses the slot 4 with LFO 1 on the destination *Pitch* - which moves all three oscillators at once - and the tremolo the slot 5 with LFO 2 on the destination *VCA*; the slots 6-40 and the LFOs 3-6 are never written. The tremolo runs its oscillator unipolar with a negative amount, so it attenuates from the full level of the amplifier downwards, and its depth is scaled with the loudness law of the device, where the maximum depth of 96 dB is the swing down to silence. Both oscillators are written in the *Poly* mode, so that a vibrato and a tremolo belong to the played note. A low frequency oscillator of a read preset which is synchronized to the tempo of the song is not converted, since a multi-sample does not carry a tempo; a phase which the device runs freely is not converted either. The device has no fade-out for a modulation, therefore the *Decay* of a written oscillator is off. +A vibrato, a tremolo and a modulation of the filter cutoff are carried through the modulation matrix. Of its 40 slots the first three are taken by the pitch envelopes of the three oscillators, so the vibrato uses the slot 4 with LFO 1 on the destination *Pitch* - which moves all three oscillators at once -, the tremolo the slot 5 with LFO 2 on the destination *VCA* and the filter modulation the slot 6 with LFO 3 on the destination *Filter1 Cutoff*; the slots 7-40 and the LFOs 4-6 are never written. The device squares the amount of a pitch destination before it applies it and reaches 24 semitones with the full amount (Iridium MK2 firmware 4.0.6), so a pitch envelope and a vibrato are written with the square root of their share of that range - a vibrato of 50 cents takes an amount of 14.4 %. The amount of the filter modulation is not squared, its full amount moves the cutoff across the whole range of the filter, 11.25 octaves. The tremolo runs its oscillator unipolar with a negative amount, so it attenuates from the full level of the amplifier downwards, and its depth is scaled with the loudness law of the device, where the maximum depth of 96 dB is the swing down to silence. All three oscillators are written in the *Poly* mode, so that the modulations belong to the played note. The list of destinations grew with the firmware, so the index of a destination depends on the format version of the preset - the *VCA* is 107 up to version 13, 108 in version 14 and 117 in version 15 - and the device looks a destination up by its name. Destinations are written with their name and the index of the written version and read by their name, which finds the modulations of presets written by older firmware versions as well. A low frequency oscillator of a read preset which is synchronized to the tempo of the song is not converted, since a multi-sample does not carry a tempo; a phase which the device runs freely is not converted either. The device has no fade-out for a modulation, therefore the *Decay* of a written oscillator is off. + +A monophonic source - one with a polyphony of 1 or one which is played legato - is written with the mono mode of the preset, and a preset in the mono mode is read with a polyphony of 1, played legato. A portamento is written as the glide of the preset: its time is the *Glide Rate*, which the device plays as a glide of the same length between any two notes, at most 2 seconds, in the *Legato* mode for a legato source and in the *Onset* mode otherwise. When the zones of a group which follow the keyboard share a key tracking other than 100 %, it becomes the key tracking of their oscillator. Since the device has a field of its own for the bank, a source which carries its bank in front of its name is written with that bank removed from the preset name - the device shows it in the Bank field anyway and the display offers little room. The file name keeps the full name, so the files can still be told apart in a file browser. An explicit *Bank* from the destination options replaces the source's bank, which is then no longer written anywhere else, so in that case the preset name keeps it - but only as long as the qualified name fits into the name field, which holds 32 characters (the manual of the Iridium MK2 states the same limit for a name entered on the device). Everything beyond that is cut off, which removes exactly the part that tells the presets of one bank apart, so such a name is written without the bank of the source. diff --git a/documentation/design/WALDORF_QPAT_FORMAT.md b/documentation/design/WALDORF_QPAT_FORMAT.md index 9dde25aa..2599d539 100644 --- a/documentation/design/WALDORF_QPAT_FORMAT.md +++ b/documentation/design/WALDORF_QPAT_FORMAT.md @@ -283,7 +283,7 @@ The 18 parameter records, in file order: | `AmpEnvDelay` | 0 | 0.00 secs | | | `AmpEnvAttack` | 0 | 0.00 secs | instant | | `AmpEnvDecay` | 0 | 0.00 secs | instant | -| `AmpEnvRelease` | 0.407283 | 1.00 secs | `0.06 * 1000^0.407283` = 1.0 s | +| `AmpEnvRelease` | 0.415718 | 1.00 secs | `0.06 * 1000^0.415718 - 0.06` = 1.0 s | | `AmpEnvSustain` | 1 | 100.00 % | | | `AmpEnvAttackCurve` | 2 | Lin | | | `AmpEnvDecayCurve` | 2 | Lin | | @@ -507,19 +507,23 @@ All envelopes share one layout and the same laws: | stage | law (x = stored value) | range | |-------|------------------------|-------| | Delay | `t = 2 x^2` s | 0 .. 2 s | -| Attack, Decay, Release | `t = 0.06 x 1000^x` s; **x = 0 is played as an instant stage** | instant, then 0.06 s .. 60 s | +| Attack, Decay, Release | `t = 0.06 x 1000^x - 0.06` s; **x = 0 is instant** (hw) | 0 .. 59.94 s | | Sustain | level `x` | 0..1 = 0..100 % | | AttackCurve | enum 0 = *Exp*, 1 = *RC*, 2 = *Lin* | | | DecayCurve, ReleaseCurve | enum 0 = *Exp*, 1 = *Exp alt*, 2 = *Lin* | | -Inverse for a writer: `x = log(t / 0.06) / log(1000)` for `t > 0.06`, and `x = 0` (instant) for -anything at or below 0.06 s. The firmware's display formatter computes the time as -`60 x 10^(3 (x - 1)) - 0.001` s **(fw)**, the same curve shifted by one millisecond. +Inverse for a writer: `x = log(1 + t / 0.06) / log(1000)`. The display of the device shows +`60 x 10^(3 (x - 1)) - 0.001` s **(fw)**, which is 59 ms more than the sound engine plays - an +earlier version of this document and of ConvertWithMoss used the display law, which made every stage +60 ms too short and wrote everything below 0.06 s as instant. Measured on an Iridium MK2 with OS 4.0.6 +with linear pitch envelopes and `EnvelopeVar` at 0: values written for 0.07, 0.1, 0.25, 0.5, 1 and +2 s with the display law played 0.011, 0.041, 0.190, 0.441, 0.940 and 1.940 s, for the attack, the +decay and the release alike and within 2 ms on repeated notes **(hw)**. -There is **nothing between instant and ~0.06 s**. For the amplitude envelope this matters: an -instant stage opens or closes the amplifier within one sample, which clicks unless the audio is at -zero at that moment. Measured on the device, **0.07 s is the shortest stage which renders without a -click** **(hw)**. See section 7.2 for how to choose between 0 and 0.07 s. +The curve runs continuously from instant upwards. For the amplitude envelope a very short stage +opens or closes the amplifier so fast that it clicks unless the audio is at zero at that moment. +Measured on the device, **a stage of 10 ms renders without a click** **(hw)** - the test wrote it as +0.07 s with the display law. See section 7.2 for how to choose between 0 and 10 ms. Curve types: *Exp* and *RC* (attack) / *Exp alt* (decay, release) are the two curved variants, *Lin* is linear. Write the option index. The device rounds the value of an enumeration to the nearest @@ -541,25 +545,59 @@ Each of the 40 slots has four parameters: | parameter | encoding / meaning | |-----------|--------------------| | `MatrixOnOff{k}` | 0 = *Disabled*, 1 = *Active* | -| `MatrixSrc{k}` | source index: 4-6 = *Free Env 1-3*, 7-12 = *LFO 1-6* (fw). Other sources exist and are not covered | -| `MatrixDst{k}` | destination index: 1 = *Pitch* (all three oscillators at once), 2-4 = *Osc1-3 Pitch*, 117 = *VCA* (fw). Other destinations exist and are not covered | +| `MatrixSrc{k}` | source index: 4-6 = *Free Env 1-3*, 7-12 = *LFO 1-6*, the same in every format version (fw, corpus). Other sources exist and are not covered | +| `MatrixDst{k}` | destination index, **which depends on the format version** (table below): 1 = *Pitch* (all three oscillators at once) and 2-4 = *Osc1-3 Pitch* in every version | | `MatrixAmount{k}` | bipolar, `2x - 1` = -100..+100 % | -The matrix adds `sourcexamount` to the destination in the destination's own units and clamps the -result to the destination's range **(fw)**. - -* **Pitch destinations: +/-100 % = +/-24 semitones.** An amount of +12.5 % with a unipolar source at - full level raises the pitch by 3 semitones; a full-scale bipolar LFO at +12.5 % sweeps +/-3 - semitones. A pitch envelope is therefore a free envelope into *Osc{i} Pitch* with an amount of - `semitones / 24`. -* **VCA destination (117).** The amplifier already plays at full level, so a positive modulation is +**The device resolves an enumeration by its name.** When it loads a patch it looks the hint of every +enumeration up among the option names of the parameter and takes the index of the option it finds; +only an empty hint, or one which names no option, leaves the stored value in place **(fw: +`PatchLib::migrateIOAreaToStagingArea` calls `Param::DiscreteNameToIndex`)**. The hint therefore wins +over the value - a writer which changes the value of an enumeration must change or clear its hint as +well. This is what keeps old patches working while the list of destinations grew: + +| destination | version 9 | versions 10-13 | version 14 | version 15 | +|-------------|-----------|----------------|------------|------------| +| *Filter1 Cutoff* | 99 | 99 | 100 | 109 | +| *VCA* | 105 | 107 | 108 | 117 | + +The indices of versions 9-14 come from the hints of device-written patches (corpus), those of +version 15 from the list of the firmware 4.0.6 (fw): version 14 added *DF Tilt* in front of the +filter destinations and version 15 the nine *SD1-3 Position/Detune/Spread* destinations of the Seeds +oscillators. A reader matches the hint first and falls back to the index of the file's version; a +writer writes the name and the index of the version it writes. + +The matrix adds `source x amount` to a sum per destination, clamps the sum to +/-100 and the voice +applies it in the destination's own units **(fw: `ModMatrix::calc`)**. A destination can be +registered to **square the amount**, keeping its sign, before it is applied. + +* **Pitch destinations: the amount is squared, +/-100 % = +/-24 semitones.** *Pitch* and + *Osc1-3 Pitch* carry the squaring flag and the voice multiplies their sum with 24 semitones + **(fw)**. An amount of +50 % with a source at full level raises the pitch by `0.5^2 x 24` = 6 + semitones; +14.4 % gives half a semitone. A pitch envelope is therefore a free envelope into + *Osc{i} Pitch* with an amount of `sign x sqrt(|semitones| / 24)`. The factory patches agree: their + vibratos on the modulation wheel use control amounts of 11-20 %, which is 29-96 cents squared and + would be 2.6-4.8 semitones linear (corpus). Measured on an Iridium MK2 with OS 4.0.6: a free envelope + held at full level into *Osc2 Pitch* at +50 % plays the second oscillator 6.00 semitones above the + first, at two different keys, and a converted vibrato of 50 cents at 5 Hz swings +/-49.97 cents at + 4.999 Hz, where the linear amount of earlier versions swings +/-1.04 cents, and a converted pitch + envelope of 12 semitones starts every note 12.02 semitones high **(hw)**. Earlier versions of this document and of ConvertWithMoss + used `semitones / 24`, which writes every pitch modulation below 24 semitones too shallow: a + 50 cent vibrato became one of about 1 cent. +* **Filter cutoff (*Filter1 Cutoff*): linear, +/-100 % = +/-135 semitones.** The voice adds the sum + to the cutoff in the units of `Filter1CutOff`, whose range of 0..1 covers 11.25 octaves; the key + tracking of the filter is scaled into the same units with 1/135 per semitone **(fw)**. +8.9 % with a + source at full level raises the cutoff by an octave. Measured on an Iridium MK2: a unipolar square + LFO at +8.89 % switches a resonant 24 dB low-pass at 1000 Hz between 1000 Hz and 11.75 +/- 0.5 + semitones above it; a law of 120 semitones would give 10.7 **(hw)**. +* **VCA destination.** The amplifier already plays at full level, so a positive modulation is clamped and only a *negative* excursion is audible. The attenuation reached at the end of the swing follows the level law of `Osc{i}Vol`: `dB = 40 log10(1 - |amount|)`; silence (96 dB down) is an amount of -99.6 %, i.e. the end of the range. A tremolo is an LFO set to **unipolar** with a **negative** amount, so it only attenuates downwards from the full level; a bipolar LFO would press the first half of every cycle against the upper end of the amplifier and leave the rectified - half of the waveform. (The dB scale of this destination is derived from the level law, not - measured.) + half of the waveform. The destination is not squared (fw); its dB scale is derived from the level + law and not measured. ### 5.8 Low frequency oscillators @@ -579,7 +617,7 @@ result to the destination's range **(fw)**. | quantity | stored value x -> unit | inverse | |----------|------------------------|---------| -| envelope stage time | `0.06 x 1000^x` s, 0 = instant | `log(t / 0.06) / log(1000)`; `t <= 0.06` -> 0 | +| envelope stage time | `0.06 x 1000^x - 0.06` s, 0 = instant | `log(1 + t / 0.06) / log(1000)` | | envelope delay | `2 x^2` s | `sqrt(t / 2)` | | level (`Osc{i}Vol`) | `40 log10(x)` dB | `10^(dB / 40)` | | filter cut-off | `8.1758 x 2^(11.25 x)` Hz | `log2(f / 8.1758) / 11.25` | @@ -592,6 +630,30 @@ result to the destination's range **(fw)**. | LFO delay / attack / decay | `20 x^2` / `10 x^2` / `10 x^2` s | `sqrt(t / max)` | | map gain | linear factor, `20 log10(g)` dB | `10^(dB / 20)` | | map positions | fraction of the frame count | `frames / count` | +| matrix amount on a pitch destination | `sign(a) x a^2 x 24` semitones | `sign x sqrt(|semitones| / 24)` | +| matrix amount on *Filter1 Cutoff* | `a x 135` semitones | `semitones / 135` | +| glide time (`GlideRate`) | `2 x^2` s | `sqrt(t / 2)` | + +### 5.10 Voices and glide + +| parameter | encoding / meaning | +|-----------|--------------------| +| `PolyMonoMode` | 0 = *Poly*, 1 = *Mono* | +| `LayerVoices` | the voices of a layer, option index + 1 (3 = 4 voices, the default). Only used in the split mode, which shares the 16 voices of the instrument among its layers (manual) - it does not limit the polyphony of a patch in the other modes | +| `GlideOnOff` | 0 = *Off*, 1 = *On* | +| `GlideRate` | `t = 2 x^2` s, 0..2 s; the default 0.15 is 45 ms. The glide takes this time for every interval: the step per block of 128 samples is `interval x 128 / (88200 x x^2)` (fw). Measured: 0.7071 glides for 1.000 s over 200 and over 400 cents, on a straight line of pitch (hw) | +| `GlideType` | 0 = *Onset*, glides to every new note; 1 = *Legato*, only to a note played while another one is held (manual) | +| `PitchVariation` | analog drift: every oscillator of every voice gets a random detune, scaled by this value; default 0.17 (manual) | +| `EnvelopeVariation` | the attack and decay of every note deviate randomly from their values, scaled by this value; default 0.35 (manual) | + +Waldorf keeps `PitchVariation` and `EnvelopeVariation` at their defaults in 234 of the 242 sample-based +factory patches (corpus); a patch which does not store them gets the defaults (2.3). + +In the *Mono* mode every new note retriggers the amplitude envelope - its level follows the velocity of +each note - unless `AmpEnvSingleTrig` is on (manual, hw), and a note which does not overlap the previous +one restarts the sample: the sounding waveform is cut within 2-3 samples and the sample starts again +from its first frame. With an instant attack and a sustained sample this is an audible click, while an +overlapping note glides on without one (hw). ## 6. Metadata: name, author, bank, attributes @@ -631,10 +693,11 @@ no filter, or 0 plus the `Filter1*` parameters), the eight `AmpEnv*` parameters Add `Matrix*`, `FreeEnv*` and `Lfo*` parameters only for the modulations you actually use - and then all parameters of the LFO or envelope you use. -ConvertWithMoss never writes more than five matrix slots: slots 1-3 carry the pitch envelopes of +ConvertWithMoss never writes more than six matrix slots: slots 1-3 carry the pitch envelopes of oscillators 1-3 (*Free Env 1-3* into *Osc1-3 Pitch*), slot 4 a vibrato (*LFO 1* into *Pitch*, one -slot for all three oscillators) and slot 5 a tremolo (*LFO 2* into *VCA*, unipolar, negative -amount). Slots 6-40 and LFOs 3-6 are left untouched for the user. +slot for all three oscillators), slot 5 a tremolo (*LFO 2* into *VCA*, unipolar, negative amount) +and slot 6 a modulation of the filter cutoff (*LFO 3* into *Filter1 Cutoff*, bipolar). Slots 7-40 and +LFOs 4-6 are left untouched for the user. ### 7.2 Pitfalls seen on the device @@ -654,15 +717,15 @@ All of these were found by loading written patches on an Iridium **(hw)**: * **`Osc{i}ParticleSampleMode = 2`** or single samples do not track the keyboard. * **Key tracking is 0.75 for 1:1**, not 1.0 - for the oscillator and for `Filter1Keytrack` alike. 1.0 is +200 % and opens the filter twice as far per octave. -* **Envelope stages shorter than ~0.06 s do not exist**; 0 is instant and clicks unless the audio is - at zero at that moment: - - *Release*: write at least 0.07 s. A note-off lands at an arbitrary point of the waveform (for a +* **Very short envelope stages click**; 0 is instant and clicks unless the audio is at zero at that + moment: + - *Release*: write at least 10 ms. A note-off lands at an arbitrary point of the waveform (for a looped entry at full level), so an instant release always clicks. - *Attack*: an instant attack is fine - and is what the device itself uses for percussive sounds (35 % of 141 patches written by an Iridium set it to 0) - **when the sample starts near zero**. - Lifting every short attack to 0.07 s erases the strike of a percussive recording (a 3 ms attack - became 70 ms, 23 times longer). ConvertWithMoss lifts an attack only when the first frame at the - entry's start is above 2 % of the sample's peak level; otherwise it writes 0. + Lifting every short attack erases the strike of a percussive recording. ConvertWithMoss lifts an + attack below 10 ms to 10 ms only when the first frame at the entry's start is above 2 % of the + sample's peak level; otherwise it writes the attack of the source. - *Attack 0 + Decay 0 + Sustain below 100 %* pops: the device snaps to the 100 % attack peak and drops instantly to the sustain level. Write such an envelope with sustain 1.0 and apply the sustain level to the map gains instead. @@ -784,7 +847,9 @@ fourth layer plays its three. can be reduced to their plain type. * Scan the 40 matrix slots: an active slot with a free envelope source and an oscillator's pitch as destination is that oscillator's pitch envelope; an LFO into *Pitch* or an oscillator's pitch is a - vibrato, an LFO into *VCA* a tremolo (convert the amount with the laws of 5.7). Skip an LFO with + vibrato, an LFO into *VCA* a tremolo and an LFO into *Filter1 Cutoff* a filter modulation (convert + the amount with the laws of 5.7 - the pitch amount is squared). Match a destination by its hint + and only without one by the index of the file's version (5.7). Skip an LFO with `Lfo{n}Sync = 1` unless you know the tempo, and read a phase at or above 0.9986 as free running. * Accept non-integral enumeration values (section 5.5). * **Layers** (section 2.6): read the u16 at 428. 1 = one more layer at the offset in 432; 2 = up to @@ -806,17 +871,15 @@ fourth layer plays its three. * **`Expalt` curve value.** The enumeration index of *Exp alt* is 1; ConvertWithMoss writes 0.5 and the device accepts the file, but how it rounds a non-integral enumeration value has not been checked. Writing 1.0 matches the index. -* **VCA destination scale.** The dB per percent of matrix destination 117 is derived from the level - law and not measured on the device. +* **VCA destination scale.** The dB per percent of the matrix destination *VCA* is derived from the + level law and not measured on the device. * **Loop cross-fade unit.** Column 14 is a fraction and the device plays it (hw); whether it relates to the loop length or to the sample length is not verified. Device-written maps fit a loop-relative reading (521 looped entries with a cross-fade on one card: 517 fit in front of the loop start relative to the loop length, 61 relative to the sample length). -* **Envelope time law.** The firmware's display formatter differs from the law above by a constant 1 - ms; whether the audio engine uses the display law or a neighbouring variant which subtracts in the - normalized domain (59 ms different at the fast end) is inferred, not measured. The firmware also - has an `EnvelopeVar` parameter (default 0.35) which randomizes attack and decay per note; zero it - before measuring envelope times on the device. +* **Envelope delay.** The law of the delay stages (`2 x^2` s) is read from the display formatter and + not measured; `EnvelopeVar` (default 0.35) randomizes attack and decay per note, so zero it before + measuring envelope times on the device. * **Layers.** Three-layer files and layer count codes other than 1 and 2 have not been observed; whether an MK2 can write code 1, and whether a first-generation instrument accepts code 2, is unknown. The loader accepts the codes 1, 2 and 3 as "more than one layer" and loads the layers 3 @@ -841,7 +904,7 @@ fourth layer plays its three. types, the granular sample modes (`Osc{i}ParticleSampleMode` 0 *Granular* and 1 *Live Granular*), the selection rule for overlapping entries (`Osc{i}ParticleSelectionRule`: *Round Robin*, *Reverse Robin*, *Ping Pong*, *Random Robin*, *Random*, *None*), filter 2 and the saturated/dirty - filter variants, the other matrix sources and destinations, split key windows + filter variants, the matrix sources and destinations other than those of 5.7, split key windows (`Osc{i}MinNote`/`MaxNote`), the *Global* and *Single Trig* LFO modes, tempo-synchronized LFOs. ## Appendix: how ConvertWithMoss maps the format @@ -857,8 +920,11 @@ For readers who want to compare an implementation against ConvertWithMoss (`Wald 1/2/3*; filter, amplifier envelope, velocity amount, vibrato and tremolo of the layer are applied to every zone. An entry whose velocity window ends at 0 is skipped. The four attributes are fed through its keyword detector to derive a category and keywords; author -> creator, bank -> - description. Matrix pitch amounts are scaled to its model's depth of 12000 cents - (`depth = amount x 24 x 100 / 12000`), the VCA amount to its 96 dB volume depth. + description. Matrix destinations are matched by their hint, then by the index of the version. Matrix + pitch amounts are squared and scaled to its model's depth of 12000 cents + (`depth = sign x amount^2 x 24 x 100 / 12000`), a cutoff amount with 135 semitones + (`depth = amount x 135 x 100 / 12000`), the VCA amount to its 96 dB volume depth. *Mono* becomes a + polyphony of 1 played legato, an active glide the portamento time (`2 x GlideRate^2` s). * **Writing**: split-stereo groups are combined into stereo files; a group whose zones stack (overlap in key *and* velocity) is partitioned into layers of non-overlapping zones, largest first. The resulting groups fill the three oscillators of a layer and then, when the option allows @@ -868,11 +934,14 @@ For readers who want to compare an implementation against ConvertWithMoss (`Wald instead (section 4.1). Everything beyond the first layer is written in the Multi/Layered mode. The common gain and panning of a group go to `Osc{i}Vol` / `Osc{i}Pan`, the remainder into the map. Filter, amplifier envelope, velocity and LFOs are taken from the first zone of the first group; - the pitch envelope from the first zone of each group. The samples of the later layers are named + the pitch envelope from the first zone of each group. A polyphony of 1 or a legato source is + written with *Mono*, a portamento as the glide (*Legato* for a legato source, *Onset* otherwise), a + key tracking which all key-tracked zones of a group share as `Osc{i}Keytrack`. Matrix destinations + are written with their name and the index of the written version (5.7). The samples of the later layers are named in the last map of the first layer in entries which never play (7.5). * **Policies**: the de-click and flat-envelope rules of section 7.2; hold + decay are added into the - Decay stage; a pitch envelope which starts at a level is written as attack 0 and a decay of the - source's attack time; the preset name drops a leading bank because the bank field holds it (unless + Decay stage; a pitch envelope which starts at a level above 0 is written as attack 0 and a decay of + the source's attack time (an unset start level is written as a normal envelope); the preset name drops a leading bank because the bank field holds it (unless an explicit bank option replaces the source's bank, in which case the name keeps it as long as it fits 32 characters); categories are translated into the factory spelling (*Keyboard -> Keys, Bell -> Bells, Percussion/Hi-Hat/Kick/Snare/Clap -> Percussive, Loops -> Loop, Acoustic Drum -> Drum, 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 844c230a..d88c7ab0 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 @@ -137,8 +137,19 @@ public class WaldorfQpatCreator extends AbstractWavCreator /** The length of the import number prefix, e.g. '05002-'. */ private static final int NUMBER_PREFIX_LENGTH = 6; private static final WaldorfQpatResourceHeader EMPTY_RESOURCE_HEADER = new WaldorfQpatResourceHeader (); - /** The shortest amplitude attack/release which the device renders without a click. */ - private static final double DECLICK_SECONDS = 0.07; + /** + * The shortest amplitude attack/release which the device renders without a click. The hardware + * test which established it wrote 0.07 seconds with the display law of the envelope times, + * which the sound engine plays as 0.01 seconds, see {@link #convertFromTime(double)}. + */ + private static final double DECLICK_SECONDS = 0.01; + /** + * The time which the sound engine subtracts from the curve of the envelope times, so that the + * value 0 of a stage is instant, see {@link #convertFromTime(double)}. + */ + private static final double ENVELOPE_TIME_OFFSET = 0.06; + /** The longest envelope stage of the device in seconds, the value 1 of a stage. */ + private static final double MAX_ENVELOPE_TIME = 59.94; /** The share of the peak level at which a step in the audio becomes audible as a click. */ private static final double AUDIBLE_STEP_RATIO = 0.02; /** The lowest cutoff frequency of the filter of the device, the value 0 of Filter1CutOff. */ @@ -149,23 +160,21 @@ public class WaldorfQpatCreator extends AbstractWavCreator /** * The modulation matrix slot which routes the low frequency oscillator of the vibrato. The * slots 1-3 are already used for the pitch envelopes of the 3 oscillators, see - * {@link #createPitchEnvelopeModulator(List, IEnvelopeModulator, int)}. + * {@link #createPitchEnvelopeModulator(List, IEnvelopeModulator, int, int)}. */ private static final int MATRIX_SLOT_VIBRATO = 4; /** The modulation matrix slot which routes the low frequency oscillator of the tremolo. */ private static final int MATRIX_SLOT_TREMOLO = 5; + /** The modulation matrix slot which routes the low frequency oscillator of the filter cutoff. */ + private static final int MATRIX_SLOT_CUTOFF_LFO = 6; /** The low frequency oscillator which plays the vibrato. */ private static final int LFO_VIBRATO = 1; /** The low frequency oscillator which plays the tremolo. */ private static final int LFO_TREMOLO = 2; - /** MatrixSrc: [7] "LFO 1" [8] "LFO 2" [9] "LFO 3" [10] "LFO 4" [11] "LFO 5" [12] "LFO 6". */ - private static final int MATRIX_SRC_FIRST_LFO = 7; - /** MatrixDst: [1] "Pitch" - the pitch of all three oscillators at once. */ - private static final int MATRIX_DST_PITCH = 1; - /** MatrixDst: [117] "VCA" - the amplifier of the voice. */ - private static final int MATRIX_DST_VCA = 117; - /** The pitch which one modulation matrix slot can reach, in semi-tones. */ - private static final double MATRIX_PITCH_RANGE = 24.0; + /** The low frequency oscillator which modulates the cutoff of the filter. */ + private static final int LFO_CUTOFF = 3; + /** GlideRate: [0..1] ~ [0..2] seconds, the longest glide of the device. */ + private static final double GLIDE_MAXIMUM_TIME = 2.0; /** * The lowest rate of a low frequency oscillator in Hertz, which is one cycle in 240 seconds. */ @@ -452,7 +461,7 @@ private void storeMultisample (final IMultisampleSource multisampleSource, final // instantly drops to the sustain level. Such an envelope is meant to be flat, so write // a full sustain and fold the sustain level into the zone gain instead. final double ampGainFold = computeFlatAmpEnvelopeLevel (groups); - final List parameters = createParameters (groups, ampGainFold < 1.0, numLayers > 1, multisampleSource.isMonophonicLegato ()); + final List parameters = createParameters (groups, ampGainFold < 1.0, numLayers > 1, multisampleSource, version); // The samples of the later layers are referenced from the first layer as well final List layerShadowSamples = i == 0 ? shadowSamples : Collections.emptyList (); final List sampleMaps = new ArrayList<> (); @@ -614,8 +623,9 @@ private static double computeFlatAmpEnvelopeLevel (final List groups) if (envelope == null) return 1.0; - // The minimum representable envelope time is 0.06 seconds; anything at or below that is - // written as an instant stage (see convertFromTime). + // An attack and a decay which are over within a few hundredths of a second leave the + // envelope at its sustain level from the start of the note, which is how such an envelope + // is meant. final double attackTime = envelope.getAttackTime (); final double decayTime = Math.max (0, envelope.getHoldTime ()) + Math.max (0, envelope.getDecayTime ()); double sustainLevel = envelope.getSustainLevel (); @@ -1037,14 +1047,30 @@ private static boolean zonesOverlap (final ISampleZone a, final ISampleZone b) } - private static List createParameters (final List groups, final boolean flattenAmpEnvelope, final boolean isMultiLayer, final boolean isMonophonic) + /** + * Create the parameters of one layer. + * + * @param groups The groups of the layer, one for each oscillator + * @param flattenAmpEnvelope True to write the amplitude envelope with a full sustain, see + * {@link #computeFlatAmpEnvelopeLevel(List)} + * @param isMultiLayer True if the patch has more than one layer + * @param multisampleSource The multi-sample source + * @param version The format version of the patch + * @return The parameters + */ + private static List createParameters (final List groups, final boolean flattenAmpEnvelope, final boolean isMultiLayer, final IMultisampleSource multisampleSource, final int version) { final List parameters = new ArrayList<> (); - // PolyMonoMode: [0] "Poly", [1] "Mono" - a monophonic source plays one voice at a time - if (isMonophonic) + // PolyMonoMode: [0] "Poly", [1] "Mono" - a monophonic source plays one voice at a time. The + // device has no other limit for the voices of a patch - LayerVoices is only used in the + // split mode, which divides the voices of the device among its layers + final boolean isMonophonicLegato = multisampleSource.isMonophonicLegato (); + if (isMonophonicLegato || multisampleSource.getPolyphony () == 1) parameters.add (new WaldorfQpatParameter ("PolyMonoMode", "Mono", 1.0f)); + createGlideParameters (parameters, multisampleSource.getPortamentoTime (), isMonophonicLegato); + if (isMultiLayer) { // All layers sound simultaneously over the whole keyboard range @@ -1073,12 +1099,15 @@ private static List createParameters (final List g parameters.add (new WaldorfQpatParameter ("Osc" + groupIndex + "CoarsePitch", "+0 semi", 24.0f)); parameters.add (new WaldorfQpatParameter ("Osc" + groupIndex + "FinePitch", "+0.0 cents", 0.5f)); - // Osc1PitchBendRange: [0..48] ~ [-24..24] + // Osc1PitchBendRange: [0..48] ~ [-24..24], the options are named like '+2' and '-2' final int pitchbend = Math.clamp (Math.round (firstZone.getBendUp () / 100.0), -24, 24); - parameters.add (new WaldorfQpatParameter ("Osc" + groupIndex + "PitchBendRange", (pitchbend < 0 ? "-" : "+") + pitchbend, pitchbend + 24.0f)); + parameters.add (new WaldorfQpatParameter ("Osc" + groupIndex + "PitchBendRange", (pitchbend < 0 ? "" : "+") + pitchbend, pitchbend + 24.0f)); - // Osc1Keytrack: [0..1] ~ [-200..200] - already set in the sample maps - parameters.add (new WaldorfQpatParameter ("Osc" + groupIndex + "Keytrack", "+100.0", 0.75f)); + // Osc1Keytrack: [0..1] ~ [-200..200] %, 0.75 is +100 %. It scales the tracking of the + // entries of the sample map which follow the keyboard; an entry with a fixed pitch is + // marked in the map (TrackPitch) + final double keyTracking = getOscillatorKeyTracking (sampleZones); + parameters.add (new WaldorfQpatParameter ("Osc" + groupIndex + "Keytrack", String.format (Locale.US, "%+.1f", Double.valueOf (keyTracking * 100.0)), (float) (0.5 + keyTracking / 4.0))); // Osc1Vol: [0..1] ~ [-inf dB..0.000 dB]. The oscillator is the group, so the group's // gain offset is stored here and the remainder per zone in the sample map. A source @@ -1093,7 +1122,7 @@ private static List createParameters (final List g final String panningStr = panningOffset == 0 ? "Center" : StringUtils.formatPercent (panningOffset, 2); parameters.add (new WaldorfQpatParameter ("Osc" + groupIndex + "Pan", panningStr, (float) ((panningOffset + 1.0) / 2.0))); - createPitchEnvelopeModulator (parameters, firstZone.getPitchEnvelopeModulator (), i + 1); + createPitchEnvelopeModulator (parameters, firstZone.getPitchEnvelopeModulator (), i + 1, version); if (i == 0) { @@ -1110,7 +1139,7 @@ private static List createParameters (final List g final double ampVeloAmount = firstZone.getAmplitudeVelocityModulator ().getDepth (); parameters.add (new WaldorfQpatParameter ("AmpVeloAmount", StringUtils.formatPercent (ampVeloAmount, 2), (float) ((ampVeloAmount + 1.0) / 2.0))); - createLfoModulators (parameters, firstZone); + createLfoModulators (parameters, firstZone, version); } } @@ -1118,7 +1147,69 @@ private static List createParameters (final List g } - private static void createPitchEnvelopeModulator (final List parameters, final IEnvelopeModulator pitchEnvelopeModulator, final int oscIndex) + /** + * Create the parameters of the glide. The device glides from the pitch of the previous note to + * the pitch of the new one in the time of GlideRate, whatever the interval: the time is 2 x + * rate^2 seconds (Iridium MK2 firmware 4.0.6, the step of the glide is the interval x 128 / + * (88200 x rate^2) per block of 128 samples, the same law as the display). Without a portamento + * nothing is written, which leaves the glide off. + * + * @param parameters Where to add the parameters + * @param portamentoTime The portamento time in seconds, 0 if there is none + * @param isMonophonicLegato True if the source glides only to notes which are played legato + */ + private static void createGlideParameters (final List parameters, final double portamentoTime, final boolean isMonophonicLegato) + { + if (portamentoTime <= 0) + return; + + // GlideOnOff: [0] "Off" [1] "On" + parameters.add (new WaldorfQpatParameter ("GlideOnOff", "On", 1.0f)); + // GlideRate: [0..1] ~ [0..2] seconds + final double glideTime = Math.min (portamentoTime, GLIDE_MAXIMUM_TIME); + parameters.add (new WaldorfQpatParameter ("GlideRate", formatSeconds (glideTime), (float) Math.sqrt (glideTime / GLIDE_MAXIMUM_TIME))); + // GlideType: [0] "Onset" glides to every new note, [1] "Legato" only to a note which is + // played while another one is still held + parameters.add (new WaldorfQpatParameter ("GlideType", isMonophonicLegato ? "Legato" : "Onset", isMonophonicLegato ? 1.0f : 0.0f)); + } + + + /** + * Get the key tracking to write into the oscillator of a group. The oscillator holds one key + * tracking for all entries of its sample map which follow the keyboard, therefore a tracking + * other than 100 % can only be written if all such zones share it. + * + * @param zones The zones of the group + * @return The key tracking in the range of [0..2], 1 is the tracking of the keyboard + */ + private static double getOscillatorKeyTracking (final List zones) + { + double keyTracking = -1; + for (final ISampleZone zone: zones) + { + // A zone without key tracking is written as an entry with a fixed pitch + final double zoneKeyTracking = zone.getKeyTracking (); + if (zoneKeyTracking <= 0) + continue; + if (keyTracking < 0) + keyTracking = zoneKeyTracking; + else if (Math.abs (zoneKeyTracking - keyTracking) > 0.0001) + return 1; + } + return keyTracking < 0 ? 1 : Math.clamp (keyTracking, 0, 2); + } + + + /** + * Create the pitch envelope of an oscillator: its free envelope, routed through the matrix slot + * of the same index. + * + * @param parameters Where to add the parameters + * @param pitchEnvelopeModulator The pitch envelope modulator of the group + * @param oscIndex The index of the oscillator [1..3] + * @param version The format version of the patch, which decides the index of a destination + */ + private static void createPitchEnvelopeModulator (final List parameters, final IEnvelopeModulator pitchEnvelopeModulator, final int oscIndex, final int version) { // Use the matrix slots 1-3 and free envelopes 1-3 for the respective oscillator 1-3 // modulation @@ -1133,7 +1224,8 @@ private static void createPitchEnvelopeModulator (final List * The vibrato modulates the destination "Pitch", which is the pitch of all three oscillators at * once. This costs one slot instead of one slot per oscillator and matches a vibrato of a @@ -1157,8 +1250,9 @@ private static void createPitchEnvelopeModulator (final List parameters, final ISampleZone zone) + private static void createLfoModulators (final List parameters, final ISampleZone zone, final int version) { // Vibrato - the pitch swings around the played note, therefore the LFO stays bipolar final ILfoModulator pitchLfoModulator = zone.getPitchLfoModulator (); @@ -1166,11 +1260,8 @@ private static void createLfoModulators (final List parame final double pitchDepth = pitchLfoModulator.getDepth (); if (pitchDepth != 0 && pitchLfo.isSet ()) { - // The depth of the model covers IEnvelope#MAX_ENVELOPE_DEPTH cent, one matrix slot - // reaches MATRIX_PITCH_RANGE semi-tones - final double semitones = pitchDepth * IEnvelope.MAX_ENVELOPE_DEPTH / 100.0; - final double amount = Math.clamp (semitones / MATRIX_PITCH_RANGE, -1.0, 1.0); - createModulationMatrixEntry (parameters, MATRIX_SLOT_VIBRATO, LFO_VIBRATO, "Pitch", MATRIX_DST_PITCH, amount); + final String destination = WaldorfQpatModulationMatrix.DESTINATION_PITCH; + createModulationMatrixEntry (parameters, MATRIX_SLOT_VIBRATO, LFO_VIBRATO, destination, WaldorfQpatModulationMatrix.getDestinationIndex (destination, version), convertFromPitchDepth (pitchDepth)); createLfo (parameters, pitchLfo, LFO_VIBRATO, false); } @@ -1187,9 +1278,31 @@ private static void createLfoModulators (final List parame // own, therefore only its magnitude is written final double decibels = Math.abs (amplitudeDepth) * ILfoModulator.MAX_VOLUME_DEPTH; final double amount = convertFromDecibels (-decibels) - 1.0; - createModulationMatrixEntry (parameters, MATRIX_SLOT_TREMOLO, LFO_TREMOLO, "VCA", MATRIX_DST_VCA, amount); + final String destination = WaldorfQpatModulationMatrix.DESTINATION_VCA; + createModulationMatrixEntry (parameters, MATRIX_SLOT_TREMOLO, LFO_TREMOLO, destination, WaldorfQpatModulationMatrix.getDestinationIndex (destination, version), amount); createLfo (parameters, amplitudeLfo, LFO_TREMOLO, true); } + + // Filter cutoff - the cutoff swings around its value, therefore the LFO stays bipolar. The + // device adds the modulation to the cutoff in the units of Filter1CutOff, whose range covers + // WaldorfQpatModulationMatrix#CUTOFF_RANGE semi-tones, and does not square its amount. The + // modulation is only written with a filter which is written as active, see + // createFilterParameters + final Optional optFilter = zone.getFilter (); + if (optFilter.isEmpty () || optFilter.get ().getType () == FilterType.BAND_REJECTION) + return; + final ILfoModulator cutoffLfoModulator = optFilter.get ().getCutoffLfoModulator (); + final ILfo cutoffLfo = cutoffLfoModulator.getSource (); + final double cutoffDepth = cutoffLfoModulator.getDepth (); + if (cutoffDepth != 0 && cutoffLfo.isSet ()) + { + // The depth of the model covers IEnvelope#MAX_ENVELOPE_DEPTH cent + final double semitones = cutoffDepth * IEnvelope.MAX_ENVELOPE_DEPTH / 100.0; + final double amount = Math.clamp (semitones / WaldorfQpatModulationMatrix.CUTOFF_RANGE, -1.0, 1.0); + final String destination = WaldorfQpatModulationMatrix.DESTINATION_FILTER1_CUTOFF; + createModulationMatrixEntry (parameters, MATRIX_SLOT_CUTOFF_LFO, LFO_CUTOFF, destination, WaldorfQpatModulationMatrix.getDestinationIndex (destination, version), amount); + createLfo (parameters, cutoffLfo, LFO_CUTOFF, false); + } } @@ -1209,9 +1322,10 @@ private static void createModulationMatrixEntry (final List parameters, // Only the amplitude envelope gates the VCA, so only it can click when a stage is instant; // a short filter or pitch envelope stage is left unchanged. final boolean isAmplitude = AMP_ENV.equals (prefix); + // A pitch envelope which starts at a level of its own falls from it to the sustain level + // during its attack time. The start level -1 means that the source does not set one, such + // an envelope rises from zero through its attack and decay like every other one + final boolean startsAtLevel = isPitch && envelope.getStartLevel () > 0; - if (isPitch && envelope.getStartLevel () != 0) + if (startsAtLevel) { // xxxEnvDelay parameters.add (new WaldorfQpatParameter (prefix + TAG_DELAY, formatSeconds (0), 0)); // xxxEnvAttack parameters.add (new WaldorfQpatParameter (prefix + TAG_ATTACK, formatSeconds (0), 0)); // xxxEnvDecay - final double decayTime = Math.clamp (envelope.getAttackTime (), 0, 60); + final double decayTime = Math.clamp (envelope.getAttackTime (), 0, MAX_ENVELOPE_TIME); parameters.add (new WaldorfQpatParameter (prefix + TAG_DECAY, formatSeconds (decayTime), (float) convertFromTime (decayTime))); } else @@ -1419,15 +1537,15 @@ private static void createEnvelope (final List parameters, final double delayTime = Math.clamp (envelope.getDelayTime (), 0, 2); parameters.add (new WaldorfQpatParameter (prefix + TAG_DELAY, formatSeconds (delayTime), (float) convertFromDelayTime (delayTime))); // xxxEnvAttack - final double attackTime = declickAmpTime (isAmplitude && !allowInstantAttack, Math.clamp (envelope.getAttackTime (), 0, 60)); + final double attackTime = declickAmpTime (isAmplitude && !allowInstantAttack, Math.clamp (envelope.getAttackTime (), 0, MAX_ENVELOPE_TIME)); parameters.add (new WaldorfQpatParameter (prefix + TAG_ATTACK, formatSeconds (attackTime), (float) convertFromTime (attackTime))); // xxxEnvDecay - final double decayTime = Math.clamp (Math.max (0, envelope.getHoldTime ()) + Math.max (0, envelope.getDecayTime ()), 0, 60); + final double decayTime = Math.clamp (Math.max (0, envelope.getHoldTime ()) + Math.max (0, envelope.getDecayTime ()), 0, MAX_ENVELOPE_TIME); parameters.add (new WaldorfQpatParameter (prefix + TAG_DECAY, formatSeconds (decayTime), (float) convertFromTime (decayTime))); } // xxxEnvRelease - final double releaseTime = declickAmpRelease (isAmplitude, Math.clamp (envelope.getReleaseTime (), 0, 60)); + final double releaseTime = declickAmpRelease (isAmplitude, Math.clamp (envelope.getReleaseTime (), 0, MAX_ENVELOPE_TIME)); parameters.add (new WaldorfQpatParameter (prefix + "Release", formatSeconds (releaseTime), (float) convertFromTime (releaseTime))); // xxxEnvSustain - a flattened amplitude envelope sustains at full level; its level is @@ -1439,7 +1557,7 @@ private static void createEnvelope (final List parameters, sustainLevel = 1; parameters.add (new WaldorfQpatParameter (prefix + "Sustain", StringUtils.formatPercent (sustainLevel, 2), (float) sustainLevel)); - if (isPitch && envelope.getStartLevel () != 0) + if (startsAtLevel) { // xxxDecayCurve: [0] "Exp" [1] "Exp alt" [2] "Lin" final double decaySlope = envelope.getAttackSlope (); @@ -1622,17 +1740,17 @@ private static double getGroupPanningOffset (final IGroup group) /** * Convert the depth of a pitch modulation of the model into the amount of a modulation matrix - * slot. The depth of the model covers {@link IEnvelope#MAX_ENVELOPE_DEPTH} cent, while one slot - * of the matrix reaches {@link #MATRIX_PITCH_RANGE} semi-tones - a modulation which asks for - * more than the device can pitch is written at the end of its range. + * slot. The depth of the model covers {@link IEnvelope#MAX_ENVELOPE_DEPTH} cent, while the + * device squares the amount of a pitch destination, see + * {@link WaldorfQpatModulationMatrix#PITCH_RANGE} - a modulation which asks for more than the + * device can pitch is written at the end of its range. * * @param depth The modulation depth in the range of [-1..1] * @return The amount in the range of [-1..1] */ private static double convertFromPitchDepth (final double depth) { - final double semitones = depth * IEnvelope.MAX_ENVELOPE_DEPTH / 100.0; - return Math.clamp (semitones / MATRIX_PITCH_RANGE, -1.0, 1.0); + return WaldorfQpatModulationMatrix.convertSemitonesToPitchAmount (depth * IEnvelope.MAX_ENVELOPE_DEPTH / 100.0); } @@ -1651,11 +1769,10 @@ private static double convertFromDelayTime (final double y) /** - * The device plays an envelope stage with parameter value 0 as instant. For the amplitude - * envelope a non-zero attack or release shorter than the ~0.06 second minimum would otherwise - * collapse to instant and click on note-on/off for a sample that does not start or end at a - * zero crossing. Clamp such a time up to the shortest audible length (0.07 seconds, verified on - * Iridium hardware); a genuine zero stays instant. Only the amplitude envelope gates the VCA, + * A very short attack or release of the amplitude envelope opens or closes the VCA so fast that + * it clicks on note-on/off for a sample which does not start or end at a zero crossing. Such a + * time is lifted to the shortest length which renders without a click (0.01 seconds, verified + * on Iridium hardware); a genuine zero stays instant. Only the amplitude envelope gates the VCA, * so a short filter or pitch envelope stage is left unchanged. * * @param declick True to lift the stage to the shortest audible length @@ -1728,15 +1845,23 @@ private static double declickAmpRelease (final boolean isAmplitude, final double } - private static double convertFromTime (final double y) + /** + * Convert the time of an envelope stage into the value of its parameter. The sound engine plays + * the value x of an attack, a decay or a release as 60 x 10^(3 (x - 1)) - 0.06 seconds: 0 is + * instant and 1 is 59.94 seconds. The display of the device shows the same curve minus 0.001 + * seconds instead, which is 59 ms longer than what is played. Measured on an Iridium MK2 with OS + * 4.0.6 with linear pitch envelopes: stages written for 0.07, 0.1, 0.25, 0.5, 1 and 2 seconds + * with the display law took 0.011, 0.041, 0.190, 0.441, 0.940 and 1.940 seconds, alike for the + * attack, the decay and the release. + * + * @param seconds The time in seconds + * @return The parameter value in the range of [0..1] + */ + private static double convertFromTime (final double seconds) { - // The minimum representable time is 0.06 seconds (parameter value 0). Anything at or below - // that - including a zero attack/decay/release - maps to 0. Without this guard the - // logarithm returns negative values, and exactly 0 yields negative infinity, which would be - // written as a corrupt float and produce e.g. a click at the start of every note. - if (y <= 0.06) + if (seconds <= 0) return 0; - return Math.clamp (Math.log (y / 0.06) / Math.log (1000), 0, 1); + return Math.clamp (Math.log (1.0 + seconds / ENVELOPE_TIME_OFFSET) / Math.log (1000), 0, 1); } diff --git a/src/main/java/de/mossgrabers/convertwithmoss/format/waldorf/qpat/WaldorfQpatDetector.java b/src/main/java/de/mossgrabers/convertwithmoss/format/waldorf/qpat/WaldorfQpatDetector.java index 8b816adb..0f1b4b68 100644 --- a/src/main/java/de/mossgrabers/convertwithmoss/format/waldorf/qpat/WaldorfQpatDetector.java +++ b/src/main/java/de/mossgrabers/convertwithmoss/format/waldorf/qpat/WaldorfQpatDetector.java @@ -62,18 +62,8 @@ public class WaldorfQpatDetector extends AbstractDetector { private static final String TAG_MATRIX_DST = "MatrixDst"; - /** The number of slots of the modulation matrix of the device. */ - private static final int MAX_MATRIX_SLOTS = 40; - /** The number of low frequency oscillators of the device. */ - private static final int NUM_LFOS = 6; - /** MatrixSrc: [7] "LFO 1" [8] "LFO 2" [9] "LFO 3" [10] "LFO 4" [11] "LFO 5" [12] "LFO 6". */ - private static final int MATRIX_SRC_FIRST_LFO = 7; - /** MatrixDst: [1] "Pitch" - the pitch of all three oscillators at once. */ - private static final int MATRIX_DST_PITCH = 1; - /** MatrixDst: [117] "VCA" - the amplifier of the voice. */ - private static final int MATRIX_DST_VCA = 117; - /** The pitch which one modulation matrix slot can reach, in semi-tones. */ - private static final double MATRIX_PITCH_RANGE = 24.0; + /** GlideRate: the value which the device uses when a patch does not store it. */ + private static final double GLIDE_RATE_DEFAULT = 0.15; /** * The lowest rate of a low frequency oscillator in Hertz, which is one cycle in 240 seconds. */ @@ -294,7 +284,7 @@ private IMultisampleSource parseLayer (final byte [] fileData, final int layerOf return null; } - if (!this.readSampleMaps (in, file, multisampleSource, resources, parameters)) + if (!this.readSampleMaps (in, file, multisampleSource, resources, parameters, version)) { // Only a layer which plays no samples at all is skipped; a patch without any such // layer is reported by the caller @@ -347,10 +337,11 @@ private static int readUnsigned32 (final byte [] data, final int offset) * @param multisampleSource Where to store the groups * @param resources The resource headers of the sample maps * @param parameters The parameters of the layer + * @param version The format version of the patch * @return True if the layer contains at least one sample map * @throws IOException Could not read the sample maps */ - private boolean readSampleMaps (final InputStream in, final File file, final IMultisampleSource multisampleSource, final WaldorfQpatResourceHeader [] resources, final Map parameters) throws IOException + private boolean readSampleMaps (final InputStream in, final File file, final IMultisampleSource multisampleSource, final WaldorfQpatResourceHeader [] resources, final Map parameters, final long version) throws IOException { // Read all sample maps (max. 3, one for each oscillator) final byte [] resourcesData = in.readAllBytes (); @@ -374,9 +365,22 @@ private boolean readSampleMaps (final InputStream in, final File file, final IMu // PolyMonoMode: [0] "Poly", [1] "Mono" final WaldorfQpatParameter polyMonoMode = parameters.get ("PolyMonoMode"); if (polyMonoMode != null && polyMonoMode.value >= 0.5) + { + multisampleSource.setPolyphony (1); multisampleSource.setMonophonicLegato (true); + } + + // GlideOnOff: [0] "Off" [1] "On", GlideRate: [0..1] ~ [0..2] seconds. The device glides + // from the pitch of the previous note to the pitch of the new one in this time, whatever + // the interval (Iridium MK2 firmware 4.0.6) + final WaldorfQpatParameter glideOnOff = parameters.get ("GlideOnOff"); + if (glideOnOff != null && glideOnOff.value >= 0.5) + { + final WaldorfQpatParameter glideRate = parameters.get ("GlideRate"); + multisampleSource.setPortamentoTime (convertGlideTime (glideRate == null ? GLIDE_RATE_DEFAULT : glideRate.value)); + } - this.applyParameters (groupsArray, parameters); + this.applyParameters (groupsArray, parameters, version); return true; } @@ -424,8 +428,9 @@ private boolean readSampleMaps (final InputStream in, final File file, final IMu * * @param groups The 3 groups, might contain null entries! * @param parameters The parameters to apply + * @param version The format version of the patch */ - private void applyParameters (final IGroup [] groups, final Map parameters) + private void applyParameters (final IGroup [] groups, final Map parameters, final long version) { // The volume, gain and panning of the layer sit on top of the oscillators. LayerVolume: // [0..1] ~ [-inf dB..0 dB] with the law of the oscillator volume, LayerGain: [0..1] ~ @@ -507,7 +512,7 @@ private void applyParameters (final IGroup [] groups, final Map 0 || maxNote < 127) clipKeyWindow (group, minNote, maxNote); - final Optional filter = parseFilter (parameters); + final Optional filter = parseFilter (parameters, version); final IEnvelope ampEnvelope = parseEnvelope (parameters, "AmpEnv", "AmpEnv"); // AmpVeloAmount: [0.00] "-100.00 %" ... [1.00] "+100.00 %" @@ -516,9 +521,9 @@ private void applyParameters (final IGroup [] groups, final Map modulator = findPitchEnvelopeModMatrixEntry (parameters, i + 1); - final Optional pitchLfoModulator = findPitchLfoModMatrixEntry (parameters, i + 1); - final Optional amplitudeLfoModulator = findAmplitudeLfoModMatrixEntry (parameters); + final Optional modulator = findPitchEnvelopeModMatrixEntry (parameters, i + 1, version); + final Optional pitchLfoModulator = findPitchLfoModMatrixEntry (parameters, i + 1, version); + final Optional amplitudeLfoModulator = findAmplitudeLfoModMatrixEntry (parameters, version); // The oscillator volume, panning and tuning belong to the whole oscillator, which is // one group. Record them as the group offsets - the values are additionally flattened @@ -572,9 +577,10 @@ private void applyParameters (final IGroup [] groups, final Map parseFilter (final Map parameters) + private static Optional parseFilter (final Map parameters, final long version) { // FilterState: [0] "Active" [1] "Bypass" [2] "Off" final WaldorfQpatParameter filterStateParameter = parameters.get ("FilterState"); @@ -636,6 +642,14 @@ private static Optional parseFilter (final Map cutoffLfoModulator = findCutoffLfoModMatrixEntry (parameters, version); + if (cutoffLfoModulator.isPresent ()) + { + final ILfoModulator lfoModulator = filter.getCutoffLfoModulator (); + lfoModulator.setDepth (cutoffLfoModulator.get ().getDepth ()); + lfoModulator.setSource (cutoffLfoModulator.get ().getSource ()); + } + return Optional.of (filter); } @@ -990,9 +1004,17 @@ private static int parseVelocity (final String value) } - private static Optional findPitchEnvelopeModMatrixEntry (final Map parameters, final int oscIndex) + /** + * Find a modulation matrix slot which routes a free envelope to the pitch of an oscillator. + * + * @param parameters The parameters of the preset + * @param oscIndex The index of the oscillator [1..3] + * @param version The format version of the patch + * @return The pitch envelope, if there is one + */ + private static Optional findPitchEnvelopeModMatrixEntry (final Map parameters, final int oscIndex, final long version) { - for (int i = 1; i <= MAX_MATRIX_SLOTS; i++) + for (int i = 1; i <= WaldorfQpatModulationMatrix.NUM_SLOTS; i++) { // MatrixOnOffX: [0] "Disabled" [1] "Active" final WaldorfQpatParameter isActiveParam = parameters.get ("MatrixOnOff" + i); @@ -1001,23 +1023,25 @@ private static Optional findPitchEnvelopeModMatrixEntry (fin // MatrixSrcX: [4] "Free Env1" [5] "Free Env2" [6] "Free Env3" final WaldorfQpatParameter sourceParam = parameters.get ("MatrixSrc" + i); - if (sourceParam.value == 4.0 || sourceParam.value == 5.0 || sourceParam.value == 6.0) + if (sourceParam == null) + continue; + final int envelopeIndex = WaldorfQpatModulationMatrix.getSourceIndex (sourceParam) - WaldorfQpatModulationMatrix.SOURCE_FIRST_FREE_ENVELOPE + 1; + if (envelopeIndex < 1 || envelopeIndex > WaldorfQpatModulationMatrix.NUM_FREE_ENVELOPES) + continue; + + // MatrixDstX: [2] "Osc1 Pitch" [3] "Osc2 Pitch" [4] "Osc3 Pitch" + if (!WaldorfQpatModulationMatrix.isDestination (parameters.get (TAG_MATRIX_DST + i), WaldorfQpatModulationMatrix.getOscillatorPitchDestination (oscIndex), version)) + continue; + + // MatrixAmountX: [0.00] "-100.00 %" ... [1.00] "+100.00 %" + final double amount = getMatrixAmount (parameters, i); + if (amount != 0) { - // MatrixDstX: [2] "Osc1 Pitch" [3] "Osc2 Pitch" [4] "Osc3 Pitch" - final WaldorfQpatParameter destParam = parameters.get (TAG_MATRIX_DST + i); - if (destParam != null && destParam.value == oscIndex + 1.0) - { - // MatrixAmountX: [0.00] "-100.00 %" ... [1.00] "+100.00 %" - final double amount = getMatrixAmount (parameters, i); - if (amount != 0) - { - final IEnvelopeModulator modulator = new DefaultEnvelopeModulator (convertToPitchDepth (amount)); - final String prefix = "FreeEnv" + (int) (sourceParam.value - 3.0); - final IEnvelope envelope = parseEnvelope (parameters, prefix, prefix); - modulator.setSource (envelope); - return Optional.of (modulator); - } - } + final IEnvelopeModulator modulator = new DefaultEnvelopeModulator (convertToPitchDepth (amount)); + final String prefix = "FreeEnv" + envelopeIndex; + final IEnvelope envelope = parseEnvelope (parameters, prefix, prefix); + modulator.setSource (envelope); + return Optional.of (modulator); } } @@ -1031,11 +1055,12 @@ private static Optional findPitchEnvelopeModMatrixEntry (fin * * @param parameters The parameters of the preset * @param oscIndex The index of the oscillator [1..3] + * @param version The format version of the patch * @return The vibrato, if there is one */ - private static Optional findPitchLfoModMatrixEntry (final Map parameters, final int oscIndex) + private static Optional findPitchLfoModMatrixEntry (final Map parameters, final int oscIndex, final long version) { - for (int i = 1; i <= MAX_MATRIX_SLOTS; i++) + for (int i = 1; i <= WaldorfQpatModulationMatrix.NUM_SLOTS; i++) { final int lfoIndex = getActiveLfoSource (parameters, i); if (lfoIndex < 0) @@ -1043,17 +1068,14 @@ private static Optional findPitchLfoModMatrixEntry (final Map modulator = createLfoModulator (parameters, lfoIndex, depth); + final Optional modulator = createLfoModulator (parameters, lfoIndex, convertToPitchDepth (amount)); if (modulator.isPresent ()) return modulator; } @@ -1067,19 +1089,19 @@ private static Optional findPitchLfoModMatrixEntry (final Map findAmplitudeLfoModMatrixEntry (final Map parameters) + private static Optional findAmplitudeLfoModMatrixEntry (final Map parameters, final long version) { - for (int i = 1; i <= MAX_MATRIX_SLOTS; i++) + for (int i = 1; i <= WaldorfQpatModulationMatrix.NUM_SLOTS; i++) { final int lfoIndex = getActiveLfoSource (parameters, i); if (lfoIndex < 0) continue; - // MatrixDstX: [117] "VCA" - final WaldorfQpatParameter destParam = parameters.get (TAG_MATRIX_DST + i); - if (destParam == null || destParam.value != MATRIX_DST_VCA) + // MatrixDstX: "VCA" + if (!WaldorfQpatModulationMatrix.isDestination (parameters.get (TAG_MATRIX_DST + i), WaldorfQpatModulationMatrix.DESTINATION_VCA, version)) continue; final double amount = getMatrixAmount (parameters, i); @@ -1101,6 +1123,42 @@ private static Optional findAmplitudeLfoModMatrixEntry (final Map } + /** + * Find a modulation matrix slot which routes a low frequency oscillator to the cutoff of the + * filter. The device adds the modulation to the cutoff in the units of Filter1CutOff, whose range + * covers {@link WaldorfQpatModulationMatrix#CUTOFF_RANGE} semi-tones. + * + * @param parameters The parameters of the preset + * @param version The format version of the patch + * @return The modulation of the cutoff, if there is one + */ + private static Optional findCutoffLfoModMatrixEntry (final Map parameters, final long version) + { + for (int i = 1; i <= WaldorfQpatModulationMatrix.NUM_SLOTS; i++) + { + final int lfoIndex = getActiveLfoSource (parameters, i); + if (lfoIndex < 0) + continue; + + // MatrixDstX: "Filter1 Cutoff" + if (!WaldorfQpatModulationMatrix.isDestination (parameters.get (TAG_MATRIX_DST + i), WaldorfQpatModulationMatrix.DESTINATION_FILTER1_CUTOFF, version)) + continue; + + final double amount = getMatrixAmount (parameters, i); + if (amount == 0) + continue; + + // The depth of the model covers IEnvelope#MAX_ENVELOPE_DEPTH cent + final double depth = amount * WaldorfQpatModulationMatrix.CUTOFF_RANGE * 100.0 / IEnvelope.MAX_ENVELOPE_DEPTH; + final Optional modulator = createLfoModulator (parameters, lfoIndex, Math.clamp (depth, -1.0, 1.0)); + if (modulator.isPresent ()) + return modulator; + } + + return Optional.empty (); + } + + /** * Get the index of the low frequency oscillator which drives an active modulation matrix slot. * @@ -1118,9 +1176,10 @@ private static int getActiveLfoSource (final Map p // MatrixSrcX: [7] "LFO 1" ... [12] "LFO 6" final WaldorfQpatParameter sourceParam = parameters.get ("MatrixSrc" + slot); - if (sourceParam == null || sourceParam.value < MATRIX_SRC_FIRST_LFO || sourceParam.value > MATRIX_SRC_FIRST_LFO + NUM_LFOS - 1) + if (sourceParam == null) return -1; - return (int) sourceParam.value - MATRIX_SRC_FIRST_LFO + 1; + final int lfoIndex = WaldorfQpatModulationMatrix.getSourceIndex (sourceParam) - WaldorfQpatModulationMatrix.SOURCE_FIRST_LFO + 1; + return lfoIndex < 1 || lfoIndex > WaldorfQpatModulationMatrix.NUM_LFOS ? -1 : lfoIndex; } @@ -1234,15 +1293,28 @@ private static double convertLfoTime (final double x, final double maximum) /** * Convert the amount of a modulation matrix slot into the depth of a pitch modulation of the - * model. One slot of the matrix reaches {@link #MATRIX_PITCH_RANGE} semi-tones, while the depth - * of the model covers {@link IEnvelope#MAX_ENVELOPE_DEPTH} cent. + * model. The device squares the amount of a pitch destination, see + * {@link WaldorfQpatModulationMatrix#PITCH_RANGE}, while the depth of the model covers + * {@link IEnvelope#MAX_ENVELOPE_DEPTH} cent. * * @param amount The amount in the range of [-1..1] * @return The modulation depth in the range of [-1..1] */ private static double convertToPitchDepth (final double amount) { - return amount * MATRIX_PITCH_RANGE * 100.0 / IEnvelope.MAX_ENVELOPE_DEPTH; + return WaldorfQpatModulationMatrix.convertPitchAmountToSemitones (amount) * 100.0 / IEnvelope.MAX_ENVELOPE_DEPTH; + } + + + /** + * Convert the glide rate into the time of the glide. The device glides for 2 x rate^2 seconds. + * + * @param rate The value of GlideRate in the range of [0..1] + * @return The time in seconds + */ + private static double convertGlideTime (final double rate) + { + return 2.0 * rate * rate; } @@ -1261,9 +1333,16 @@ private static double convertDelayTime (final double x) } + /** + * Convert the value of an attack, a decay or a release into its time. The sound engine plays the + * value x as 60 x 10^(3 (x - 1)) - 0.06 seconds, so 0 is instant (measured on an Iridium MK2 + * with OS 4.0.6; the display of the device shows 59 ms more). + * + * @param x The parameter value in the range of [0..1] + * @return The time in seconds in the range of [0..59.94] + */ private static double convertTime (final double x) { - // Converts [0..1] to [0..60] seconds - return 0.06 * Math.pow (1000, x); + return 0.06 * Math.pow (1000, x) - 0.06; } } diff --git a/src/main/java/de/mossgrabers/convertwithmoss/format/waldorf/qpat/WaldorfQpatModulationMatrix.java b/src/main/java/de/mossgrabers/convertwithmoss/format/waldorf/qpat/WaldorfQpatModulationMatrix.java new file mode 100644 index 00000000..67d65e41 --- /dev/null +++ b/src/main/java/de/mossgrabers/convertwithmoss/format/waldorf/qpat/WaldorfQpatModulationMatrix.java @@ -0,0 +1,205 @@ +// 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.format.waldorf.qpat; + +/** + * The sources and destinations of the modulation matrix of the Waldorf Quantum/Iridium which are + * read and written, and how the device applies the amount of a slot to them. + *

+ * The sources keep their indices in all format versions, the destinations do not: the list grew + * with the firmware. The format version 14 added 'DF Tilt' in front of the destinations of the + * filters and the version 15 the nine destinations of the Seeds oscillators, which moved e.g. the + * 'VCA' from 107 to 108 and then to 117 - the indices of the version 15 are the ones of the Iridium + * MK2 firmware 4.0.6, the older ones are taken from the hints of patches which the device wrote. + * Therefore the device does not rely on the stored index when it loads a patch: for every + * enumeration whose hint holds the name of one of its options it takes the index of that option and + * only uses the stored value if the name is not found (Iridium MK2 firmware 4.0.6, + * PatchLib::migrateIOAreaToStagingArea and Param::DiscreteNameToIndex). + * + * @author Jürgen Moßgraber + */ +public class WaldorfQpatModulationMatrix +{ + /** The number of slots of the modulation matrix. */ + public static final int NUM_SLOTS = 40; + /** The number of free envelopes. */ + public static final int NUM_FREE_ENVELOPES = 3; + /** The number of low frequency oscillators. */ + public static final int NUM_LFOS = 6; + + /** MatrixSrc: [4] "Free Env1" [5] "Free Env2" [6] "Free Env3". */ + public static final int SOURCE_FIRST_FREE_ENVELOPE = 4; + /** MatrixSrc: [7] "LFO 1" [8] "LFO 2" [9] "LFO 3" [10] "LFO 4" [11] "LFO 5" [12] "LFO 6". */ + public static final int SOURCE_FIRST_LFO = 7; + + /** MatrixDst: the pitch of all three oscillators at once. */ + public static final String DESTINATION_PITCH = "Pitch"; + /** MatrixDst: the cutoff of the first filter. */ + public static final String DESTINATION_FILTER1_CUTOFF = "Filter1 Cutoff"; + /** MatrixDst: the amplifier of the voice. */ + public static final String DESTINATION_VCA = "VCA"; + + /** + * The pitch in semi-tones which a pitch destination reaches with the full amount of a slot. The + * device squares the amount of a pitch destination before it applies it, keeping its sign, and + * multiplies the sum of all slots with 24 semi-tones (Iridium MK2 firmware 4.0.6: the + * destinations 1-4 are registered with the squaring flag, ModMatrix::calc squares the amount + * with fabs, the voice multiplies the sum with 24). + */ + public static final double PITCH_RANGE = 24.0; + /** + * The range in semi-tones by which the cutoff destination moves the cutoff with the full amount + * of a slot. The device adds the sum of all slots to the cutoff in the units of Filter1CutOff, + * whose range of 0 to 1 covers 11.25 octaves (Iridium MK2 firmware 4.0.6, the key tracking of + * the filter is scaled into the same units with 1/135 per semi-tone). + */ + public static final double CUTOFF_RANGE = 135.0; + + + /** + * Private due to utility class. + */ + private WaldorfQpatModulationMatrix () + { + // Intentionally empty + } + + + /** + * Get the name of the destination of the pitch of one oscillator. + * + * @param oscIndex The index of the oscillator [1..3] + * @return The name, e.g. 'Osc1 Pitch' + */ + public static String getOscillatorPitchDestination (final int oscIndex) + { + return "Osc" + oscIndex + " Pitch"; + } + + + /** + * Get the index of a destination in the list of a format version. + * + * @param name The name of the destination, one of 'Pitch', 'Osc1 Pitch', 'Osc2 Pitch', 'Osc3 + * Pitch', 'Filter1 Cutoff' and 'VCA' + * @param version The format version + * @return The index or -1 if the index of the destination is not known for the version + */ + public static int getDestinationIndex (final String name, final long version) + { + return switch (name) + { + case DESTINATION_PITCH -> 1; + case "Osc1 Pitch" -> 2; + case "Osc2 Pitch" -> 3; + case "Osc3 Pitch" -> 4; + case DESTINATION_FILTER1_CUTOFF -> getFilterCutoffIndex (version); + case DESTINATION_VCA -> getVcaIndex (version); + default -> -1; + }; + } + + + /** + * Test if a destination parameter of a slot names the given destination. Like the device, the + * name in the hint is used if the patch has one, otherwise the index of the format version. + * + * @param parameter The destination parameter of the slot, might be null + * @param name The name of the destination, see {@link #getDestinationIndex(String, long)} + * @param version The format version of the patch + * @return True if the slot modulates the destination + */ + public static boolean isDestination (final WaldorfQpatParameter parameter, final String name, final long version) + { + if (parameter == null) + return false; + if (!parameter.hint.isBlank ()) + return name.equals (parameter.hint); + final int index = getDestinationIndex (name, version); + return index >= 0 && Math.round (parameter.value) == index; + } + + + /** + * Get the index of the source of a slot. The indices of the free envelopes and the low frequency + * oscillators are the same in all format versions; like the device, the name in the hint is used + * if the patch has one. + * + * @param parameter The source parameter of the slot + * @return The index of the source + */ + public static int getSourceIndex (final WaldorfQpatParameter parameter) + { + final String hint = parameter.hint; + for (int i = 0; i < NUM_LFOS; i++) + if (hint.equals ("LFO " + (i + 1))) + return SOURCE_FIRST_LFO + i; + for (int i = 0; i < NUM_FREE_ENVELOPES; i++) + if (hint.equals ("Free Env" + (i + 1))) + return SOURCE_FIRST_FREE_ENVELOPE + i; + return Math.round (parameter.value); + } + + + /** + * Convert the amount of a slot which modulates a pitch destination into semi-tones. + * + * @param amount The amount in the range of [-1..1] + * @return The pitch in semi-tones which the full level of the source reaches + */ + public static double convertPitchAmountToSemitones (final double amount) + { + final double clampedAmount = Math.clamp (amount, -1.0, 1.0); + return Math.signum (clampedAmount) * clampedAmount * clampedAmount * PITCH_RANGE; + } + + + /** + * Convert a pitch in semi-tones into the amount of a slot which modulates a pitch destination. A + * pitch beyond the range of a slot is written at the end of the range. + * + * @param semitones The pitch in semi-tones which the full level of the source is to reach + * @return The amount in the range of [-1..1] + */ + public static double convertSemitonesToPitchAmount (final double semitones) + { + final double ratio = Math.clamp (semitones / PITCH_RANGE, -1.0, 1.0); + return Math.signum (ratio) * Math.sqrt (Math.abs (ratio)); + } + + + /** + * Get the index of the destination 'Filter1 Cutoff' in a format version. + * + * @param version The format version + * @return The index or -1 if it is not known + */ + private static int getFilterCutoffIndex (final long version) + { + if (version >= 15) + return 109; + if (version == 14) + return 100; + return version >= 9 ? 99 : -1; + } + + + /** + * Get the index of the destination 'VCA' in a format version. + * + * @param version The format version + * @return The index or -1 if it is not known + */ + private static int getVcaIndex (final long version) + { + if (version >= 15) + return 117; + if (version == 14) + return 108; + if (version >= 10) + return 107; + return version == 9 ? 105 : -1; + } +}