From 4edaba6522dad7c8bf4d478533bf7ea2d9e15001 Mon Sep 17 00:00:00 2001 From: Douglas Carmichael Date: Sat, 27 Jun 2026 04:29:56 -0400 Subject: [PATCH 01/13] Add Elektron Tonverk preset (TVPST) format support for reading and writing Reads all three generator machines (One-Shot, Multi, Drum) into the multi-sample model, including the amplitude envelope, the multi-mode filter and its envelope, sample loops, gain and panning. Writes a Multi or Drum machine preset (selectable, with an auto-from-source mode) using a neutral factory parameter template; samples are stored next to the preset and referenced relatively. The existing ElektronMulti* classes (which handle the Tonverk .elmulti/.eldrum mapping files) are renamed to TonverkMulti* so the Elektron format family is named by device. The user-facing format name and CLI prefix are unchanged. --- documentation/CHANGELOG.md | 1 + documentation/README-FORMATS.md | 28 +- .../core/ConverterBackend.java | 12 +- ...iCreator.java => TonverkMultiCreator.java} | 34 +- ...atorUI.java => TonverkMultiCreatorUI.java} | 4 +- ...etector.java => TonverkMultiDetector.java} | 26 +- ...onMultiFile.java => TonverkMultiFile.java} | 42 +- .../format/elektron/TonverkPresetCreator.java | 410 ++++++++++++++ .../elektron/TonverkPresetCreatorUI.java | 190 +++++++ .../elektron/TonverkPresetDetector.java | 442 +++++++++++++++ .../format/elektron/TonverkPresetFile.java | 505 +++++++++++++++++ .../format/elektron/TonverkValues.java | 226 ++++++++ src/main/resources/Strings.properties | 8 + .../templates/tonverk/drum-template.tvpst | 522 ++++++++++++++++++ .../templates/tonverk/multi-template.tvpst | 146 +++++ 15 files changed, 2537 insertions(+), 59 deletions(-) rename src/main/java/de/mossgrabers/convertwithmoss/format/elektron/{ElektronMultiCreator.java => TonverkMultiCreator.java} (91%) rename src/main/java/de/mossgrabers/convertwithmoss/format/elektron/{ElektronMultiCreatorUI.java => TonverkMultiCreatorUI.java} (96%) rename src/main/java/de/mossgrabers/convertwithmoss/format/elektron/{ElektronMultiDetector.java => TonverkMultiDetector.java} (86%) rename src/main/java/de/mossgrabers/convertwithmoss/format/elektron/{ElektronMultiFile.java => TonverkMultiFile.java} (89%) create mode 100644 src/main/java/de/mossgrabers/convertwithmoss/format/elektron/TonverkPresetCreator.java create mode 100644 src/main/java/de/mossgrabers/convertwithmoss/format/elektron/TonverkPresetCreatorUI.java create mode 100644 src/main/java/de/mossgrabers/convertwithmoss/format/elektron/TonverkPresetDetector.java create mode 100644 src/main/java/de/mossgrabers/convertwithmoss/format/elektron/TonverkPresetFile.java create mode 100644 src/main/java/de/mossgrabers/convertwithmoss/format/elektron/TonverkValues.java create mode 100644 src/main/resources/de/mossgrabers/convertwithmoss/templates/tonverk/drum-template.tvpst create mode 100644 src/main/resources/de/mossgrabers/convertwithmoss/templates/tonverk/multi-template.tvpst diff --git a/documentation/CHANGELOG.md b/documentation/CHANGELOG.md index e8d5bbaa..57f6d0bd 100644 --- a/documentation/CHANGELOG.md +++ b/documentation/CHANGELOG.md @@ -3,6 +3,7 @@ ## 19.0.0 (unreleased) * New: Improved user interface for long lists of formats. +* New: Added support for the Elektron Tonverk preset (TVPST) format - read (One-Shot, Multi and Drum machines, including amplitude and filter envelopes) and write (Multi or Drum machine) (thanks to Douglas Carmichael). * New: Added support for the Polyend Tracker (PTI) instrument format (thanks to Douglas Carmichael). * New: Added support for the Renoise instrument (XRNI) format (thanks to Douglas Carmichael). * New: Added support for the Synthstrom Deluge instrument format (thanks to Douglas Carmichael). diff --git a/documentation/README-FORMATS.md b/documentation/README-FORMATS.md index 7c74a50c..880d1de6 100644 --- a/documentation/README-FORMATS.md +++ b/documentation/README-FORMATS.md @@ -260,7 +260,12 @@ There is no write support. ## Elektron Tonverk The Elektron Tonverk is a dedicated hardware sampler that marks an important milestone for Elektron as its first instrument to support multi-samples. This allows users to map multiple sampled sounds across keys or velocity ranges, creating more expressive and realistic instruments than single-sample playback alone. -Sadly, the elmulti format is very basic and limited. It only supports the basic multi-sample layout does not contain any synthesizer parameters like envelopes or filter settings. + +ConvertWithMoss supports two Elektron Tonverk formats: the basic multi-sample mapping files (*.elmulti / *.eldrum) and the full preset (*.tvpst). + +### Multi-Sample Mapping (.elmulti / .eldrum) + +The elmulti format is very basic and limited. It only supports the basic multi-sample layout and does not contain any synthesizer parameters like envelopes or filter settings. Furthermore, even this basic setup has some limitations: * There are no key ranges, the Tonverk always plays the sample with the closest root note. This can lead to different key-ranges than in the source multi-sample. @@ -268,8 +273,27 @@ Furthermore, even this basic setup has some limitations: * Duplicated velocity layers always result in round-robin of these samples (they do not sound at the same time). * Only 1 Pitch per key zone can be set which means you cannot tune individual samples. -### Destination Options +#### Destination Options + +* Re-sample to 24bit/48kHz: If enabled, samples will be resampled to 24bit and 48kHz. While the device can play other resolutions as well, there are reports of issues when you do so. + +### Preset (.tvpst) + +In contrast to the mapping files, a Tonverk preset is a full sound that also contains the synthesizer parameters. All three generator machines are read: + +* **Multi**: a multi-sample mapped to key- and velocity-ranges. +* **One-Shot**: a single sample mapped across the whole keyboard. +* **Drum**: a kit of eight drum voices, each on its own key with its own settings. + +The amplitude envelope (AHD or ADSR), the multi-mode filter together with its envelope, the sample loops, gain and panning are converted. The remaining, synthesizer-specific parameters (arpeggiator, effects, global LFOs and the modulation matrix) have no equivalent in the multi-sample model and are therefore not converted. + +When writing, the samples are stored next to the preset and referenced by their relative file name, so the preset can be copied anywhere onto the SD card. The full parameter block is created from a neutral factory template (effects bypassed, LFOs, arpeggiator and modulation neutralized) and only the converted parameters above are filled in from the source. + +Note: the Tonverk stores envelope times and the filter cut-off frequency as normalized values using internal, non-published curves. ConvertWithMoss uses documented approximations for these. A Tonverk-to-Tonverk conversion is therefore loss-less, while a conversion to or from a unit-based format (such as Waldorf Quantum/Iridium or the Synthstrom Deluge) is a close approximation. + +#### Destination Options +* Output Engine: Selects which machine to write. *Multi-Sample* and *Drum Kit* force that machine; *Auto (from source)* writes a Drum machine when the source looks like a drum kit (a percussion category or up to eight single-key zones) and a Multi machine otherwise. * Re-sample to 24bit/48kHz: If enabled, samples will be resampled to 24bit and 48kHz. While the device can play other resolutions as well, there are reports of issues when you do so. ## Ensoniq EPS/EPS16+/ASR-10 diff --git a/src/main/java/de/mossgrabers/convertwithmoss/core/ConverterBackend.java b/src/main/java/de/mossgrabers/convertwithmoss/core/ConverterBackend.java index 856cdb8b..db013df6 100644 --- a/src/main/java/de/mossgrabers/convertwithmoss/core/ConverterBackend.java +++ b/src/main/java/de/mossgrabers/convertwithmoss/core/ConverterBackend.java @@ -45,8 +45,10 @@ import de.mossgrabers.convertwithmoss.format.disting.DistingExCreator; import de.mossgrabers.convertwithmoss.format.disting.DistingExDetector; import de.mossgrabers.convertwithmoss.format.dls.DlsDetector; -import de.mossgrabers.convertwithmoss.format.elektron.ElektronMultiCreator; -import de.mossgrabers.convertwithmoss.format.elektron.ElektronMultiDetector; +import de.mossgrabers.convertwithmoss.format.elektron.TonverkMultiCreator; +import de.mossgrabers.convertwithmoss.format.elektron.TonverkMultiDetector; +import de.mossgrabers.convertwithmoss.format.elektron.TonverkPresetCreator; +import de.mossgrabers.convertwithmoss.format.elektron.TonverkPresetDetector; import de.mossgrabers.convertwithmoss.format.ensoniq.epsasr.EnsoniqEpsAsrDetector; import de.mossgrabers.convertwithmoss.format.ensoniq.mirage.MirageDetector; import de.mossgrabers.convertwithmoss.format.exs.EXS24Creator; @@ -144,7 +146,8 @@ public ConverterBackend (final INotifier notifier) new DecentSamplerDetector (notifier), new DlsDetector (notifier), new DistingExDetector (notifier), - new ElektronMultiDetector (notifier), + new TonverkMultiDetector (notifier), + new TonverkPresetDetector (notifier), new EnsoniqEpsAsrDetector (notifier), new MirageDetector (notifier), new IsoDetector (notifier), @@ -179,7 +182,8 @@ public ConverterBackend (final INotifier notifier) new TX16WxCreator (notifier), new DecentSamplerCreator (notifier), new DistingExCreator (notifier), - new ElektronMultiCreator (notifier), + new TonverkMultiCreator (notifier), + new TonverkPresetCreator (notifier), new KMPCreator (notifier), new KorgmultisampleCreator (notifier), new EXS24Creator (notifier), diff --git a/src/main/java/de/mossgrabers/convertwithmoss/format/elektron/ElektronMultiCreator.java b/src/main/java/de/mossgrabers/convertwithmoss/format/elektron/TonverkMultiCreator.java similarity index 91% rename from src/main/java/de/mossgrabers/convertwithmoss/format/elektron/ElektronMultiCreator.java rename to src/main/java/de/mossgrabers/convertwithmoss/format/elektron/TonverkMultiCreator.java index 2c8b3a87..01725179 100644 --- a/src/main/java/de/mossgrabers/convertwithmoss/format/elektron/ElektronMultiCreator.java +++ b/src/main/java/de/mossgrabers/convertwithmoss/format/elektron/TonverkMultiCreator.java @@ -26,9 +26,9 @@ import de.mossgrabers.convertwithmoss.file.riff.CommonRiffChunkId; import de.mossgrabers.convertwithmoss.file.wav.WaveFile; import de.mossgrabers.convertwithmoss.file.wav.WaveRiffChunkId; -import de.mossgrabers.convertwithmoss.format.elektron.ElektronMultiFile.ElektronKeyZone; -import de.mossgrabers.convertwithmoss.format.elektron.ElektronMultiFile.ElektronSampleSlot; -import de.mossgrabers.convertwithmoss.format.elektron.ElektronMultiFile.ElektronVelocityLayer; +import de.mossgrabers.convertwithmoss.format.elektron.TonverkMultiFile.TonverkKeyZone; +import de.mossgrabers.convertwithmoss.format.elektron.TonverkMultiFile.TonverkSampleSlot; +import de.mossgrabers.convertwithmoss.format.elektron.TonverkMultiFile.TonverkVelocityLayer; import de.mossgrabers.tools.ui.Functions; @@ -38,7 +38,7 @@ * * @author Jürgen Moßgraber */ -public class ElektronMultiCreator extends AbstractWavCreator +public class TonverkMultiCreator extends AbstractWavCreator { /** * The factory default velocity. The Tonverk rejects the whole preset file if a velocity layer @@ -74,9 +74,9 @@ public class ElektronMultiCreator extends AbstractWavCreator>> velocityLayerMapEntry: multiSampleSource.getOrderedSampleZones (false).entrySet ()) { @@ -179,7 +179,7 @@ private static void prepareZones (final String presetName, final IMultisampleSou for (int roundRobinIndex = 0; roundRobinIndex < sampleZones.size (); roundRobinIndex++) { final ISampleZone zone = sampleZones.get (roundRobinIndex); - zone.setName (ElektronMultiFile.createSampleName (presetName, velocityLayerIndex, keyRoot, roundRobinIndex)); + zone.setName (TonverkMultiFile.createSampleName (presetName, velocityLayerIndex, keyRoot, roundRobinIndex)); final ISampleData sampleData = zone.getSampleData (); if (sampleData == null) @@ -199,14 +199,14 @@ private static void prepareZones (final String presetName, final IMultisampleSou } - private static ElektronMultiFile createPreset (final IMultisampleSource multiSampleSource) + static TonverkMultiFile createPreset (final IMultisampleSource multiSampleSource) { - final ElektronMultiFile elektronMulti = new ElektronMultiFile (); + final TonverkMultiFile elektronMulti = new TonverkMultiFile (); elektronMulti.name = multiSampleSource.getName (); for (final Entry>> velocityLayerMapEntry: multiSampleSource.getOrderedSampleZones (false).entrySet ()) { - final ElektronKeyZone keyZone = new ElektronKeyZone (); + final TonverkKeyZone keyZone = new TonverkKeyZone (); elektronMulti.keyZones.add (keyZone); final int keyRoot = Math.clamp (velocityLayerMapEntry.getKey ().intValue (), 0, 127); @@ -217,7 +217,7 @@ private static ElektronMultiFile createPreset (final IMultisampleSource multiSam for (final Entry> sampleZonesEntry: velocityLayerMapEntry.getValue ().entrySet ()) { - final ElektronVelocityLayer velocityLayer = new ElektronVelocityLayer (); + final TonverkVelocityLayer velocityLayer = new TonverkVelocityLayer (); keyZone.velocityLayers.add (velocityLayer); // The Tonverk rejects a velocity of exactly 0.0, use the factory default instead @@ -226,7 +226,7 @@ private static ElektronMultiFile createPreset (final IMultisampleSource multiSam for (final ISampleZone sampleZone: sampleZonesEntry.getValue ()) { - final ElektronSampleSlot sampleSlot = new ElektronSampleSlot (); + final TonverkSampleSlot sampleSlot = new TonverkSampleSlot (); velocityLayer.sampleSlots.add (sampleSlot); // Must be identical to the file name created in writeSamples! @@ -267,7 +267,7 @@ else if (tuning.doubleValue () != sampleZone.getTuning ()) } - private static boolean hasLoop (final ISampleZone zone) + static boolean hasLoop (final ISampleZone zone) { final List loops = zone.getLoops (); if (loops.isEmpty ()) @@ -287,7 +287,7 @@ private static boolean hasLoop (final ISampleZone zone) * @param multisampleSource The multi-sample source * @throws IOException Could not retrieve the current sample rate */ - private static void recalculateForResample (final IMultisampleSource multisampleSource) throws IOException + static void recalculateForResample (final IMultisampleSource multisampleSource) throws IOException { for (final IGroup group: multisampleSource.getGroups ()) for (final ISampleZone zone: group.getSampleZones ()) @@ -341,7 +341,7 @@ private static void recalculateForResample (final IMultisampleSource multisample * * @param zone The zone */ - private static void clampLoops (final ISampleZone zone) + static void clampLoops (final ISampleZone zone) { final int lastIndex = zone.getStop () - 1; if (lastIndex < 0) diff --git a/src/main/java/de/mossgrabers/convertwithmoss/format/elektron/ElektronMultiCreatorUI.java b/src/main/java/de/mossgrabers/convertwithmoss/format/elektron/TonverkMultiCreatorUI.java similarity index 96% rename from src/main/java/de/mossgrabers/convertwithmoss/format/elektron/ElektronMultiCreatorUI.java rename to src/main/java/de/mossgrabers/convertwithmoss/format/elektron/TonverkMultiCreatorUI.java index eb3602b3..7f7cc148 100644 --- a/src/main/java/de/mossgrabers/convertwithmoss/format/elektron/ElektronMultiCreatorUI.java +++ b/src/main/java/de/mossgrabers/convertwithmoss/format/elektron/TonverkMultiCreatorUI.java @@ -24,7 +24,7 @@ * * @author Jürgen Moßgraber */ -public class ElektronMultiCreatorUI extends WavChunkSettingsUI +public class TonverkMultiCreatorUI extends WavChunkSettingsUI { private static final String RESAMPLE_TO_24_48 = "ResampleTo2448"; @@ -38,7 +38,7 @@ public class ElektronMultiCreatorUI extends WavChunkSettingsUI * * @param prefix The prefix to use for the identifier */ - public ElektronMultiCreatorUI (final String prefix) + public TonverkMultiCreatorUI (final String prefix) { // Only the sample chunk is enabled by default: the Tonverk factory WAV files contain only // 'fmt ', 'data' and 'smpl' chunks and the Tonverk WAV parser is strict diff --git a/src/main/java/de/mossgrabers/convertwithmoss/format/elektron/ElektronMultiDetector.java b/src/main/java/de/mossgrabers/convertwithmoss/format/elektron/TonverkMultiDetector.java similarity index 86% rename from src/main/java/de/mossgrabers/convertwithmoss/format/elektron/ElektronMultiDetector.java rename to src/main/java/de/mossgrabers/convertwithmoss/format/elektron/TonverkMultiDetector.java index db296e66..2ead9fd5 100644 --- a/src/main/java/de/mossgrabers/convertwithmoss/format/elektron/ElektronMultiDetector.java +++ b/src/main/java/de/mossgrabers/convertwithmoss/format/elektron/TonverkMultiDetector.java @@ -24,9 +24,9 @@ import de.mossgrabers.convertwithmoss.core.model.implementation.DefaultSampleLoop; import de.mossgrabers.convertwithmoss.core.settings.MetadataSettingsUI; import de.mossgrabers.convertwithmoss.file.AudioFileUtils; -import de.mossgrabers.convertwithmoss.format.elektron.ElektronMultiFile.ElektronKeyZone; -import de.mossgrabers.convertwithmoss.format.elektron.ElektronMultiFile.ElektronSampleSlot; -import de.mossgrabers.convertwithmoss.format.elektron.ElektronMultiFile.ElektronVelocityLayer; +import de.mossgrabers.convertwithmoss.format.elektron.TonverkMultiFile.TonverkKeyZone; +import de.mossgrabers.convertwithmoss.format.elektron.TonverkMultiFile.TonverkSampleSlot; +import de.mossgrabers.convertwithmoss.format.elektron.TonverkMultiFile.TonverkVelocityLayer; /** @@ -34,14 +34,14 @@ * * @author Jürgen Moßgraber */ -public class ElektronMultiDetector extends AbstractDetector +public class TonverkMultiDetector extends AbstractDetector { /** * Constructor. * * @param notifier The notifier */ - public ElektronMultiDetector (final INotifier notifier) + public TonverkMultiDetector (final INotifier notifier) { super ("Elektron Multi", "Elektron", notifier, new MetadataSettingsUI ("Elektron"), ".elmulti", ".eldrum"); } @@ -56,7 +56,7 @@ protected List readPresetFile (final File file) try { - final ElektronMultiFile elektronMultiFile = new ElektronMultiFile (); + final TonverkMultiFile elektronMultiFile = new TonverkMultiFile (); elektronMultiFile.parse (file.toPath ()); for (final String error: elektronMultiFile.errors) @@ -73,7 +73,7 @@ protected List readPresetFile (final File file) } - private IMultisampleSource convertMultiFile (final File sourceFile, final ElektronMultiFile elektronMultiFile, final String [] parts) throws IOException + private IMultisampleSource convertMultiFile (final File sourceFile, final TonverkMultiFile elektronMultiFile, final String [] parts) throws IOException { final String multiSampleName = elektronMultiFile.name; final IMultisampleSource multisampleSource = new DefaultMultisampleSource (sourceFile, parts, multiSampleName); @@ -81,10 +81,10 @@ private IMultisampleSource convertMultiFile (final File sourceFile, final Elektr // Create all sample zones and store them by their root note and velocity low value. From // these the key-/velocity ranges need to be calculated in the next step final TreeMap>> orderedKeyRanges = new TreeMap<> (); - for (final ElektronKeyZone zone: elektronMultiFile.keyZones) + for (final TonverkKeyZone zone: elektronMultiFile.keyZones) { final Map> keyRangeMap = orderedKeyRanges.computeIfAbsent (Integer.valueOf (zone.pitch), _ -> new TreeMap<> ()); - for (final ElektronVelocityLayer velocityLayer: zone.velocityLayers) + for (final TonverkVelocityLayer velocityLayer: zone.velocityLayers) { final List sampleZones = this.createSampleZone (zone, velocityLayer, sourceFile.getParentFile ()); final int velocity = (int) Math.clamp (velocityLayer.velocity * 127.0, 0, 127.0); @@ -107,10 +107,10 @@ private IMultisampleSource convertMultiFile (final File sourceFile, final Elektr } - private List createSampleZone (final ElektronKeyZone zone, final ElektronVelocityLayer velocityLayer, final File parentFile) throws IOException + private List createSampleZone (final TonverkKeyZone zone, final TonverkVelocityLayer velocityLayer, final File parentFile) throws IOException { final List sampleZones = new ArrayList<> (); - for (final ElektronSampleSlot sampleSlot: velocityLayer.sampleSlots) + for (final TonverkSampleSlot sampleSlot: velocityLayer.sampleSlots) { final ISampleZone sampleZone = this.createSampleZone (new File (parentFile, sampleSlot.sample)); @@ -145,7 +145,7 @@ private List createSampleZone (final ElektronKeyZone zone, final El } - private static void calculateRanges (final TreeMap>> orderedKeyRanges) + static void calculateRanges (final TreeMap>> orderedKeyRanges) { if (orderedKeyRanges == null || orderedKeyRanges.isEmpty ()) return; @@ -201,7 +201,7 @@ private static void calculateRanges (final TreeMap collapseToGroups (final TreeMap>> orderedKeyRanges) + static List collapseToGroups (final TreeMap>> orderedKeyRanges) { final IGroup defaultGroup = new DefaultGroup (); final List groups = new ArrayList<> (); diff --git a/src/main/java/de/mossgrabers/convertwithmoss/format/elektron/ElektronMultiFile.java b/src/main/java/de/mossgrabers/convertwithmoss/format/elektron/TonverkMultiFile.java similarity index 89% rename from src/main/java/de/mossgrabers/convertwithmoss/format/elektron/ElektronMultiFile.java rename to src/main/java/de/mossgrabers/convertwithmoss/format/elektron/TonverkMultiFile.java index 89e513f0..4697182e 100644 --- a/src/main/java/de/mossgrabers/convertwithmoss/format/elektron/ElektronMultiFile.java +++ b/src/main/java/de/mossgrabers/convertwithmoss/format/elektron/TonverkMultiFile.java @@ -20,7 +20,7 @@ * * @author Jürgen Moßgraber */ -public class ElektronMultiFile +public class TonverkMultiFile { private static final Pattern KV = Pattern.compile ("^([A-Za-z0-9-]+)\\s*=\\s*(.+)$"); private static final String [] NOTE_NAMES = @@ -44,25 +44,25 @@ public class ElektronMultiFile /** Instrument display name. */ public String name; /** The key-zones. */ - public final List keyZones = new ArrayList<> (); + public final List keyZones = new ArrayList<> (); /** Errors happening during the parsing. */ public final List errors = new ArrayList<> (); /** A key zone. */ - public static class ElektronKeyZone + public static class TonverkKeyZone { /** MIDI note number (0-127). Defines the root note for this zone. */ public int pitch; /** Pitch center for transposition. Usually equals pitch as float. */ public double keyCenter; /** Each key zone contains one or more velocity layers. */ - public final List velocityLayers = new ArrayList<> (); + public final List velocityLayers = new ArrayList<> (); } /** A velocity layer. */ - public static class ElektronVelocityLayer + public static class TonverkVelocityLayer { /** * Velocity threshold. Sample plays when input velocity >= this value. 0.0 - 1.0 (= MIDI @@ -75,7 +75,7 @@ public static class ElektronVelocityLayer */ public String strategy = "Forward"; /** Each velocity layer contains one or more sample slots. */ - public final List sampleSlots = new ArrayList<> (); + public final List sampleSlots = new ArrayList<> (); } @@ -83,7 +83,7 @@ public static class ElektronVelocityLayer * A sample slot. Multiple sample slots under the same velocity layer create round-robin * variations. */ - public static class ElektronSampleSlot + public static class TonverkSampleSlot { /** Filename (relative path, same directory). */ public String sample; @@ -156,9 +156,9 @@ public void parse (final Path path) throws IOException { this.errors.clear (); - ElektronKeyZone currentZone = null; - ElektronVelocityLayer currentLayer = null; - ElektronSampleSlot currentSlot = null; + TonverkKeyZone currentZone = null; + TonverkVelocityLayer currentLayer = null; + TonverkSampleSlot currentSlot = null; for (final String raw: Files.readAllLines (path)) { @@ -168,7 +168,7 @@ public void parse (final Path path) throws IOException if (line.equals ("[[key-zones]]")) { - currentZone = new ElektronKeyZone (); + currentZone = new TonverkKeyZone (); this.keyZones.add (currentZone); currentLayer = null; currentSlot = null; @@ -180,7 +180,7 @@ public void parse (final Path path) throws IOException if (currentZone == null) throw new IllegalStateException ("velocity-layer without key-zone"); - currentLayer = new ElektronVelocityLayer (); + currentLayer = new TonverkVelocityLayer (); currentZone.velocityLayers.add (currentLayer); currentSlot = null; continue; @@ -190,7 +190,7 @@ public void parse (final Path path) throws IOException { if (currentLayer == null) throw new IllegalStateException ("sample-slot without velocity-layer"); - currentSlot = new ElektronSampleSlot (); + currentSlot = new TonverkSampleSlot (); currentLayer.sampleSlots.add (currentSlot); continue; } @@ -226,14 +226,14 @@ public void write (final Path path) throws IOException out.add ("version = " + this.version); out.add ("name = " + quote (this.name)); - for (final ElektronKeyZone keyZone: this.keyZones) + for (final TonverkKeyZone keyZone: this.keyZones) { out.add (""); out.add ("[[key-zones]]"); out.add ("pitch = " + keyZone.pitch); out.add ("key-center = " + formatNumber (keyZone.keyCenter)); - for (final ElektronVelocityLayer velocityLayer: keyZone.velocityLayers) + for (final TonverkVelocityLayer velocityLayer: keyZone.velocityLayers) { out.add (""); out.add ("[[key-zones.velocity-layers]]"); @@ -241,7 +241,7 @@ public void write (final Path path) throws IOException if (velocityLayer.strategy != null) out.add ("strategy = " + quote (velocityLayer.strategy)); - for (final ElektronSampleSlot sampleSlot: velocityLayer.sampleSlots) + for (final TonverkSampleSlot sampleSlot: velocityLayer.sampleSlots) { out.add (""); out.add ("[[key-zones.velocity-layers.sample-slots]]"); @@ -270,7 +270,7 @@ public void write (final Path path) throws IOException } - private void assignRoot (final ElektronMultiFile multi, final String tag, final String value) + private void assignRoot (final TonverkMultiFile multi, final String tag, final String value) { switch (tag) { @@ -281,7 +281,7 @@ private void assignRoot (final ElektronMultiFile multi, final String tag, final } - private void assignKeyZone (final ElektronKeyZone keyZone, final String tag, final String value) + private void assignKeyZone (final TonverkKeyZone keyZone, final String tag, final String value) { switch (tag) { @@ -292,7 +292,7 @@ private void assignKeyZone (final ElektronKeyZone keyZone, final String tag, fin } - private void assignVelocityLayer (final ElektronVelocityLayer velocityLayer, final String tag, final String value) + private void assignVelocityLayer (final TonverkVelocityLayer velocityLayer, final String tag, final String value) { switch (tag) { @@ -303,7 +303,7 @@ private void assignVelocityLayer (final ElektronVelocityLayer velocityLayer, fin } - private void assignSampleSlot (final ElektronSampleSlot sampleSlot, final String tag, final String value) + private void assignSampleSlot (final TonverkSampleSlot sampleSlot, final String tag, final String value) { switch (tag) { @@ -373,6 +373,6 @@ private static String formatNumber (final double value) @Override public String toString () { - return "ElektronMultiFile{name='" + this.name + "', version=" + this.version + ", keyZones=" + this.keyZones.size () + "}"; + return "TonverkMultiFile{name='" + this.name + "', version=" + this.version + ", keyZones=" + this.keyZones.size () + "}"; } } diff --git a/src/main/java/de/mossgrabers/convertwithmoss/format/elektron/TonverkPresetCreator.java b/src/main/java/de/mossgrabers/convertwithmoss/format/elektron/TonverkPresetCreator.java new file mode 100644 index 00000000..81ad4810 --- /dev/null +++ b/src/main/java/de/mossgrabers/convertwithmoss/format/elektron/TonverkPresetCreator.java @@ -0,0 +1,410 @@ +// 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.elektron; + +import java.io.BufferedReader; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +import de.mossgrabers.convertwithmoss.core.DetectSettings; +import de.mossgrabers.convertwithmoss.core.IMultisampleSource; +import de.mossgrabers.convertwithmoss.core.INotifier; +import de.mossgrabers.convertwithmoss.core.creator.AbstractWavCreator; +import de.mossgrabers.convertwithmoss.core.creator.DestinationAudioFormat; +import de.mossgrabers.convertwithmoss.core.model.IEnvelope; +import de.mossgrabers.convertwithmoss.core.model.IEnvelopeModulator; +import de.mossgrabers.convertwithmoss.core.model.IFilter; +import de.mossgrabers.convertwithmoss.core.model.IGroup; +import de.mossgrabers.convertwithmoss.core.model.IMetadata; +import de.mossgrabers.convertwithmoss.core.model.ISampleData; +import de.mossgrabers.convertwithmoss.core.model.ISampleLoop; +import de.mossgrabers.convertwithmoss.core.model.ISampleZone; +import de.mossgrabers.convertwithmoss.core.model.enumeration.FilterType; +import de.mossgrabers.convertwithmoss.file.AudioFileUtils; +import de.mossgrabers.convertwithmoss.file.riff.CommonRiffChunkId; +import de.mossgrabers.convertwithmoss.file.wav.WaveFile; +import de.mossgrabers.convertwithmoss.file.wav.WaveRiffChunkId; +import de.mossgrabers.convertwithmoss.format.elektron.TonverkMultiFile.TonverkKeyZone; +import de.mossgrabers.convertwithmoss.format.elektron.TonverkMultiFile.TonverkSampleSlot; +import de.mossgrabers.convertwithmoss.format.elektron.TonverkMultiFile.TonverkVelocityLayer; +import de.mossgrabers.convertwithmoss.format.elektron.TonverkPresetCreatorUI.OutputEngine; +import de.mossgrabers.convertwithmoss.format.elektron.TonverkPresetFile.Machine; +import de.mossgrabers.tools.ui.Functions; + + +/** + * Creator for Elektron Tonverk preset files (*.tvpst). A preset bundles a description file and the + * related (relatively referenced) samples in one folder. The full [parameters] block is + * created from a neutral factory template (FX bypassed, LFOs/arpeggiator/modulation neutralized); the + * amplitude envelope, the filter and its envelope, gain and panning are written from the model. The + * output engine (Multi or Drum) can be selected; the Drum machine always has eight voices. + * + * @author Jürgen Moßgraber + */ +public class TonverkPresetCreator extends AbstractWavCreator +{ + /** + * The factory default velocity. The Tonverk rejects the whole preset file if a velocity layer + * has a velocity of exactly 0.0. + */ + private static final double DEFAULT_VELOCITY = 0.49411765; + private static final int DRUM_VOICE_COUNT = 8; + private static final int DEFAULT_DRUM_ROOT = 60; + private static final String MULTI_TEMPLATE = "/de/mossgrabers/convertwithmoss/templates/tonverk/multi-template.tvpst"; + private static final String DRUM_TEMPLATE = "/de/mossgrabers/convertwithmoss/templates/tonverk/drum-template.tvpst"; + + private static final DestinationAudioFormat OPTIMIZED_AUDIO_FORMAT = new DestinationAudioFormat (new int [] + { + 24 + }, 48000, true); + private static final DestinationAudioFormat DEFAULT_AUDIO_FORMAT = new DestinationAudioFormat (new int [] + { + 16, + 24 + }, -1, false); + private static final Set SUPPORTED_BIT_DEPTHS = Set.of (Integer.valueOf (16), Integer.valueOf (24)); + + + /** + * Constructor. + * + * @param notifier The notifier + */ + public TonverkPresetCreator (final INotifier notifier) + { + super ("Elektron Tonverk Preset", "Tonverk", notifier, new TonverkPresetCreatorUI ("Tonverk")); + } + + + /** {@inheritDoc} */ + @Override + public void createPreset (final File destinationFolder, final IMultisampleSource multisampleSource) throws IOException + { + final boolean resample = this.settingsConfiguration.resampleTo2448 (); + final boolean drum = this.isDrumOutput (multisampleSource); + + final String sampleName = createSafeFilename (multisampleSource.getName ()); + final File presetFolder = this.createUniqueFilename (destinationFolder, sampleName, ""); + if (!presetFolder.mkdir ()) + { + this.notifier.logError ("IDS_NOTIFY_FOLDER_COULD_NOT_BE_CREATED", presetFolder.getAbsolutePath ()); + return; + } + final String presetName = presetFolder.getName (); + + multisampleSource.setGroups (this.combineSplitStereo (multisampleSource)); + + // Rename all zones to the Elektron naming convention and ensure each zone has a valid + // start/stop range (required for trimming) + TonverkMultiCreator.prepareZones (presetName, multisampleSource); + + // Must be done before the samples are written! + if (resample) + TonverkMultiCreator.recalculateForResample (multisampleSource); + + // The samples are physically trimmed to the zone start/stop and referenced relatively + this.writeSamples (presetFolder, multisampleSource, resample ? OPTIMIZED_AUDIO_FORMAT : DEFAULT_AUDIO_FORMAT, true); + + // The preset must be created after the samples were written since trimming updates the + // zone/loop positions + final TonverkPresetFile preset = drum ? this.buildDrumPreset (multisampleSource, presetName) : this.buildMultiPreset (multisampleSource); + applyMetadata (preset, multisampleSource); + + final String presetFileName = presetName + ".tvpst"; + this.notifier.log ("IDS_NOTIFY_STORING", presetFileName); + preset.write (new File (presetFolder, presetFileName).toPath ()); + + this.progress.notifyDone (); + } + + + private boolean isDrumOutput (final IMultisampleSource multisampleSource) + { + return switch (this.settingsConfiguration.getOutputEngine ()) + { + case DRUM -> true; + case MULTI -> false; + case AUTO -> looksLikeDrumKit (multisampleSource); + }; + } + + + private static boolean looksLikeDrumKit (final IMultisampleSource multisampleSource) + { + final String category = multisampleSource.getMetadata ().getCategory (); + if (category != null) + { + final String lowerCategory = category.toLowerCase (); + if (lowerCategory.contains ("drum") || lowerCategory.contains ("perc")) + return true; + } + + // Heuristic: a small number of (mostly) single-key zones + final List zones = flattenZones (multisampleSource); + if (zones.isEmpty () || zones.size () > DRUM_VOICE_COUNT) + return false; + for (final ISampleZone zone: zones) + if (zone.getKeyHigh () - zone.getKeyLow () > 1) + return false; + return true; + } + + + private TonverkPresetFile buildMultiPreset (final IMultisampleSource multisampleSource) throws IOException + { + final TonverkPresetFile preset = loadTemplate (MULTI_TEMPLATE); + preset.machine = Machine.MULTI; + + final String prefix = Machine.MULTI.getParameterPrefix (); + final ISampleZone reference = firstZone (multisampleSource); + if (reference != null) + { + applyAmplitudeEnvelope (preset, reference.getAmplitudeEnvelopeModulator ().getSource (), prefix); + reference.getFilter ().ifPresent (filter -> applyFilter (preset, filter, prefix)); + applyGainAndPanning (preset, reference.getGain (), reference.getPanning (), prefix); + } + + // Re-use the elmulti mapping builder: the key-zone structure is identical + final TonverkMultiFile mapping = TonverkMultiCreator.createPreset (multisampleSource); + preset.mappingSlotName = mapping.name; + preset.keyZones.clear (); + preset.keyZones.addAll (mapping.keyZones); + return preset; + } + + + private TonverkPresetFile buildDrumPreset (final IMultisampleSource multisampleSource, final String presetName) throws IOException + { + final TonverkPresetFile preset = loadTemplate (DRUM_TEMPLATE); + preset.machine = Machine.DRUM; + preset.mappingSlotName = presetName; + preset.keyZones.clear (); + + final List drumZones = flattenZones (multisampleSource); + if (drumZones.isEmpty ()) + return preset; + if (drumZones.size () > DRUM_VOICE_COUNT) + this.notifier.log ("IDS_TONVERK_DRUM_LIMIT", Integer.toString (drumZones.size ()), Integer.toString (DRUM_VOICE_COUNT)); + + // The Drum machine always has exactly eight voices/zones. Map the available drums to the + // first voices and, if there are fewer than eight, pad the remaining voices by cycling + // through the drums placed on free keys above the used range. + int padKey = DEFAULT_DRUM_ROOT; + for (final ISampleZone zone: drumZones) + padKey = Math.max (padKey, zone.getKeyRoot ()); + padKey++; + + for (int voice = 0; voice < DRUM_VOICE_COUNT; voice++) + { + final boolean mapped = voice < drumZones.size (); + final ISampleZone zone = drumZones.get (mapped ? voice : voice % drumZones.size ()); + final String voicePrefix = Machine.DRUM.getParameterPrefix () + "_voice" + voice; + applyAmplitudeEnvelope (preset, zone.getAmplitudeEnvelopeModulator ().getSource (), voicePrefix); + zone.getFilter ().ifPresent (filter -> applyFilter (preset, filter, voicePrefix)); + applyGainAndPanning (preset, zone.getGain (), zone.getPanning (), voicePrefix); + + final int key = Math.clamp (mapped ? zone.getKeyRoot () : padKey++, 0, 127); + preset.keyZones.add (createDrumKeyZone (zone, key)); + } + return preset; + } + + + private static TonverkKeyZone createDrumKeyZone (final ISampleZone zone, final int key) + { + final TonverkKeyZone keyZone = new TonverkKeyZone (); + keyZone.pitch = key; + keyZone.keyCenter = key - zone.getTuning (); + + final TonverkVelocityLayer velocityLayer = new TonverkVelocityLayer (); + velocityLayer.velocity = DEFAULT_VELOCITY; + keyZone.velocityLayers.add (velocityLayer); + + final TonverkSampleSlot sampleSlot = new TonverkSampleSlot (); + sampleSlot.sample = createSafeFilename (zone.getName ()) + ".wav"; + if (TonverkMultiCreator.hasLoop (zone)) + { + final ISampleLoop loop = zone.getLoops ().get (0); + sampleSlot.loopMode = "Forward"; + sampleSlot.loopStart = Integer.valueOf (Math.max (0, loop.getStart ())); + sampleSlot.loopEnd = Integer.valueOf (loop.getEnd ()); + final int crossfade = loop.getCrossfadeInSamples (); + if (crossfade > 0) + sampleSlot.loopCrossfade = Integer.valueOf (crossfade); + sampleSlot.keepLoopingOnRelease = Boolean.TRUE; + } + else + sampleSlot.loopMode = "Off"; + velocityLayer.sampleSlots.add (sampleSlot); + return keyZone; + } + + + private static void applyAmplitudeEnvelope (final TonverkPresetFile preset, final IEnvelope envelope, final String prefix) + { + // Always write an ADSR envelope; a percussive sound is represented with a sustain of 0 + put (preset, prefix + "_amp_mode", "2"); + put (preset, prefix + "_amp_env_attack", TonverkValues.attackTimeToNormalized (envelope.getAttackTime ())); + put (preset, prefix + "_amp_env_hold", 0.0); + put (preset, prefix + "_amp_env_decay", TonverkValues.decayTimeToNormalized (envelope.getDecayTime ())); + final double sustain = envelope.getSustainLevel (); + put (preset, prefix + "_amp_env_sustain", TonverkValues.clampNormalized (sustain < 0 ? 1.0 : sustain)); + put (preset, prefix + "_amp_env_release", TonverkValues.releaseTimeToNormalized (envelope.getReleaseTime ())); + } + + + private static void applyFilter (final TonverkPresetFile preset, final IFilter filter, final String prefix) + { + put (preset, prefix + "_filter_frequency", TonverkValues.cutoffToNormalized (filter.getCutoff ())); + put (preset, prefix + "_filter_resonance", TonverkValues.clampNormalized (filter.getResonance ())); + put (preset, prefix + "_filter_type", filterTypeToMorph (filter.getType ())); + + final IEnvelopeModulator cutoffModulator = filter.getCutoffEnvelopeModulator (); + final IEnvelope filterEnvelope = cutoffModulator.getSource (); + put (preset, prefix + "_filter_env_delay", TonverkValues.delayTimeToNormalized (filterEnvelope.getDelayTime ())); + put (preset, prefix + "_filter_env_attack", TonverkValues.attackTimeToNormalized (filterEnvelope.getAttackTime ())); + put (preset, prefix + "_filter_env_decay", TonverkValues.decayTimeToNormalized (filterEnvelope.getDecayTime ())); + final double sustain = filterEnvelope.getSustainLevel (); + put (preset, prefix + "_filter_env_sustain", TonverkValues.clampNormalized (sustain < 0 ? 0.0 : sustain)); + put (preset, prefix + "_filter_env_release", TonverkValues.releaseTimeToNormalized (filterEnvelope.getReleaseTime ())); + // The depth is stored bipolar with 0.5 as the center (no modulation) + put (preset, prefix + "_filter_env_depth", TonverkValues.clampNormalized (cutoffModulator.getDepth () / 2.0 + 0.5)); + } + + + private static void applyGainAndPanning (final TonverkPresetFile preset, final double gainDecibel, final double panning, final String prefix) + { + final double volume = gainDecibel <= -120.0 ? 0.0 : Math.pow (10.0, gainDecibel / 20.0); + put (preset, prefix + "_volume", TonverkValues.clampNormalized (volume)); + put (preset, prefix + "_pan", TonverkValues.clampNormalized (panning / 2.0 + 0.5)); + } + + + private static double filterTypeToMorph (final FilterType type) + { + return switch (type) + { + case HIGH_PASS -> 1.0; + case BAND_PASS -> 0.5; + // LOW_PASS and BAND_REJECTION (the Tonverk has no band-rejection) map to low-pass + default -> 0.0; + }; + } + + + private static void applyMetadata (final TonverkPresetFile preset, final IMultisampleSource multisampleSource) + { + final IMetadata metadata = multisampleSource.getMetadata (); + final String category = metadata.getCategory (); + preset.category = category == null ? "" : category; + + preset.tags.clear (); + final String [] keywords = metadata.getKeywords (); + if (keywords != null) + for (final String keyword: keywords) + if (keyword != null && !keyword.isBlank ()) + preset.tags.add (keyword); + } + + + private static TonverkPresetFile loadTemplate (final String resourcePath) throws IOException + { + final TonverkPresetFile preset = new TonverkPresetFile (); + try (final InputStream inputStream = TonverkPresetCreator.class.getResourceAsStream (resourcePath)) + { + if (inputStream == null) + throw new IOException (Functions.getMessage ("IDS_NOTIFY_ERR_LOAD_FILE", resourcePath)); + final List lines = new ArrayList<> (); + try (final BufferedReader reader = new BufferedReader (new InputStreamReader (inputStream, StandardCharsets.UTF_8))) + { + String line; + while ((line = reader.readLine ()) != null) + lines.add (line); + } + preset.parse (lines); + } + return preset; + } + + + private static void put (final TonverkPresetFile preset, final String key, final double value) + { + preset.parameters.put (key, Double.toString (value)); + } + + + private static void put (final TonverkPresetFile preset, final String key, final String value) + { + preset.parameters.put (key, value); + } + + + private static ISampleZone firstZone (final IMultisampleSource multisampleSource) + { + for (final IGroup group: multisampleSource.getGroups ()) + if (!group.getSampleZones ().isEmpty ()) + return group.getSampleZones ().get (0); + return null; + } + + + private static List flattenZones (final IMultisampleSource multisampleSource) + { + final List zones = new ArrayList<> (); + for (final IGroup group: multisampleSource.getGroups ()) + zones.addAll (group.getSampleZones ()); + return zones; + } + + + /** {@inheritDoc} */ + @Override + protected void rewriteFile (final IMultisampleSource multisampleSource, final ISampleZone zone, final OutputStream outputStream, final DestinationAudioFormat destinationFormat, final boolean trim) throws IOException + { + final ISampleData sampleData = zone.getSampleData (); + if (sampleData == null) + return; + + final WaveFile wavFile = AudioFileUtils.convertToWav (sampleData, destinationFormat); + if (wavFile.getDataChunk () == null) + throw new IOException (Functions.getMessage ("IDS_WAV_CONVERSION_FAILED", zone.getName ())); + + if (trim) + { + trimStartToEnd (wavFile, zone); + TonverkMultiCreator.clampLoops (zone); + } + + if (this.settingsConfiguration.isUpdateBroadcastAudioChunk ()) + updateBroadcastAudioChunk (multisampleSource.getMetadata (), wavFile); + if (this.settingsConfiguration.isUpdateInstrumentChunk ()) + updateInstrumentChunk (zone, wavFile); + // Only write a sample chunk when there is a loop; the Tonverk WAV parser is strict + if (this.settingsConfiguration.isUpdateSampleChunk () && TonverkMultiCreator.hasLoop (zone)) + updateSampleChunk (zone, wavFile); + if (this.settingsConfiguration.isRemoveJunkChunks ()) + wavFile.removeChunks (CommonRiffChunkId.JUNK_ID, CommonRiffChunkId.JUNK2_ID, WaveRiffChunkId.FILLER_ID, WaveRiffChunkId.MD5_ID); + + wavFile.write (outputStream); + } + + + /** {@inheritDoc} */ + @Override + public boolean checkProcessingCompatibility (final DetectSettings detectSettings) + { + if (detectSettings.reduceBitDepth <= 0 || SUPPORTED_BIT_DEPTHS.contains (Integer.valueOf (detectSettings.reduceBitDepth))) + return true; + this.notifier.log ("IDS_PROCESSING_REDUCE_BITE_DEPTH_NOT_SUPPORTED", Integer.toString (detectSettings.reduceBitDepth), "16, 24"); + return false; + } +} diff --git a/src/main/java/de/mossgrabers/convertwithmoss/format/elektron/TonverkPresetCreatorUI.java b/src/main/java/de/mossgrabers/convertwithmoss/format/elektron/TonverkPresetCreatorUI.java new file mode 100644 index 00000000..2f01a8a7 --- /dev/null +++ b/src/main/java/de/mossgrabers/convertwithmoss/format/elektron/TonverkPresetCreatorUI.java @@ -0,0 +1,190 @@ +// 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.elektron; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +import de.mossgrabers.convertwithmoss.core.INotifier; +import de.mossgrabers.convertwithmoss.core.settings.WavChunkSettingsUI; +import de.mossgrabers.tools.ui.BasicConfig; +import de.mossgrabers.tools.ui.Functions; +import de.mossgrabers.tools.ui.control.TitledSeparator; +import de.mossgrabers.tools.ui.panel.BoxPanel; +import javafx.geometry.Orientation; +import javafx.scene.control.CheckBox; +import javafx.scene.control.ComboBox; +import javafx.scene.layout.Pane; + + +/** + * Settings for the Elektron Tonverk preset (*.tvpst) creator. + * + * @author Jürgen Moßgraber + */ +public class TonverkPresetCreatorUI extends WavChunkSettingsUI +{ + /** The Tonverk generator machine to write. */ + public enum OutputEngine + { + /** Write a Multi (multi-sample) machine preset. */ + MULTI, + /** Write a Drum machine preset. */ + DRUM, + /** Choose the machine automatically from the source. */ + AUTO + } + + + private static final String OUTPUT_ENGINE = "OutputEngine"; + private static final String RESAMPLE_TO_24_48 = "ResampleTo2448"; + + private ComboBox outputEngineBox; + private CheckBox resampleTo2448CheckBox; + + private OutputEngine outputEngine = OutputEngine.MULTI; + private boolean resampleTo2448; + + + /** + * Constructor. + * + * @param prefix The prefix to use for the identifier + */ + public TonverkPresetCreatorUI (final String prefix) + { + // Only the sample chunk is enabled by default: the Tonverk factory WAV files contain only + // 'fmt ', 'data' and 'smpl' chunks and the Tonverk WAV parser is strict + super (prefix, false, false, true, true); + } + + + /** {@inheritDoc} */ + @Override + public Pane getEditPane () + { + final BoxPanel panel = new BoxPanel (Orientation.VERTICAL); + + panel.createSeparator ("@IDS_TONVERK_OUTPUT_ENGINE"); + this.outputEngineBox = new ComboBox<> (); + this.outputEngineBox.getItems ().addAll (Functions.getText ("@IDS_TONVERK_ENGINE_MULTI"), Functions.getText ("@IDS_TONVERK_ENGINE_DRUM"), Functions.getText ("@IDS_TONVERK_ENGINE_AUTO")); + this.outputEngineBox.setMaxWidth (Double.MAX_VALUE); + panel.addComponent (this.outputEngineBox); + + final TitledSeparator resampleSeparator = panel.createSeparator ("@IDS_ELEKTRON_RESAMPLE"); + resampleSeparator.getStyleClass ().add ("titled-separator-pane"); + this.resampleTo2448CheckBox = panel.createCheckBox ("@IDS_ELEKTRON_CONVERT_TO_24_48"); + + this.addWavChunkOptions (panel).getStyleClass ().add ("titled-separator-pane"); + return panel.getPane (); + } + + + /** {@inheritDoc} */ + @Override + public void loadSettings (final BasicConfig config) + { + this.outputEngineBox.getSelectionModel ().select (config.getInteger (this.prefix + OUTPUT_ENGINE, 0)); + this.resampleTo2448CheckBox.setSelected (config.getBoolean (this.prefix + RESAMPLE_TO_24_48, true)); + + super.loadSettings (config); + } + + + /** {@inheritDoc} */ + @Override + public void saveSettings (final BasicConfig config) + { + config.setInteger (this.prefix + OUTPUT_ENGINE, this.outputEngineBox.getSelectionModel ().getSelectedIndex ()); + config.setBoolean (this.prefix + RESAMPLE_TO_24_48, this.resampleTo2448CheckBox.isSelected ()); + + super.saveSettings (config); + } + + + /** {@inheritDoc} */ + @Override + public boolean checkSettingsUI (final INotifier notifier) + { + if (!super.checkSettingsUI (notifier)) + return false; + + this.outputEngine = indexToEngine (this.outputEngineBox.getSelectionModel ().getSelectedIndex ()); + this.resampleTo2448 = this.resampleTo2448CheckBox.isSelected (); + return true; + } + + + /** {@inheritDoc} */ + @Override + public boolean checkSettingsCLI (final INotifier notifier, final Map parameters) + { + if (!super.checkSettingsCLI (notifier, parameters)) + return false; + + this.outputEngine = parseEngine (parameters.remove (this.prefix + OUTPUT_ENGINE)); + this.resampleTo2448 = "1".equals (parameters.remove (this.prefix + RESAMPLE_TO_24_48)); + return true; + } + + + /** {@inheritDoc} */ + @Override + public String [] getCLIParameterNames () + { + final List parameterNames = new ArrayList<> (Arrays.asList (super.getCLIParameterNames ())); + parameterNames.add (this.prefix + OUTPUT_ENGINE); + parameterNames.add (this.prefix + RESAMPLE_TO_24_48); + return parameterNames.toArray (new String [parameterNames.size ()]); + } + + + /** + * Get the selected output engine. + * + * @return The output engine + */ + public OutputEngine getOutputEngine () + { + return this.outputEngine; + } + + + /** + * Should samples be re-sampled to 24bit and 48kHz? + * + * @return True if re-sampling should be applied + */ + public boolean resampleTo2448 () + { + return this.resampleTo2448; + } + + + private static OutputEngine indexToEngine (final int index) + { + return switch (index) + { + case 1 -> OutputEngine.DRUM; + case 2 -> OutputEngine.AUTO; + default -> OutputEngine.MULTI; + }; + } + + + private static OutputEngine parseEngine (final String value) + { + if (value == null) + return OutputEngine.MULTI; + return switch (value.trim ().toLowerCase ()) + { + case "drum" -> OutputEngine.DRUM; + case "auto" -> OutputEngine.AUTO; + default -> OutputEngine.MULTI; + }; + } +} diff --git a/src/main/java/de/mossgrabers/convertwithmoss/format/elektron/TonverkPresetDetector.java b/src/main/java/de/mossgrabers/convertwithmoss/format/elektron/TonverkPresetDetector.java new file mode 100644 index 00000000..d2615a01 --- /dev/null +++ b/src/main/java/de/mossgrabers/convertwithmoss/format/elektron/TonverkPresetDetector.java @@ -0,0 +1,442 @@ +// 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.elektron; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.TreeMap; + +import de.mossgrabers.convertwithmoss.core.INotifier; +import de.mossgrabers.convertwithmoss.core.IMultisampleSource; +import de.mossgrabers.convertwithmoss.core.detector.AbstractDetector; +import de.mossgrabers.convertwithmoss.core.detector.DefaultMultisampleSource; +import de.mossgrabers.convertwithmoss.core.model.IEnvelope; +import de.mossgrabers.convertwithmoss.core.model.IEnvelopeModulator; +import de.mossgrabers.convertwithmoss.core.model.IFilter; +import de.mossgrabers.convertwithmoss.core.model.IGroup; +import de.mossgrabers.convertwithmoss.core.model.IMetadata; +import de.mossgrabers.convertwithmoss.core.model.ISampleLoop; +import de.mossgrabers.convertwithmoss.core.model.ISampleZone; +import de.mossgrabers.convertwithmoss.core.model.enumeration.FilterType; +import de.mossgrabers.convertwithmoss.core.model.enumeration.LoopType; +import de.mossgrabers.convertwithmoss.core.model.implementation.DefaultFilter; +import de.mossgrabers.convertwithmoss.core.model.implementation.DefaultGroup; +import de.mossgrabers.convertwithmoss.core.model.implementation.DefaultSampleLoop; +import de.mossgrabers.convertwithmoss.core.settings.MetadataSettingsUI; +import de.mossgrabers.convertwithmoss.file.AudioFileUtils; +import de.mossgrabers.convertwithmoss.format.elektron.TonverkPresetFile.Machine; +import de.mossgrabers.convertwithmoss.format.elektron.TonverkMultiFile.TonverkKeyZone; +import de.mossgrabers.convertwithmoss.format.elektron.TonverkMultiFile.TonverkSampleSlot; +import de.mossgrabers.convertwithmoss.format.elektron.TonverkMultiFile.TonverkVelocityLayer; + + +/** + * Detector for Elektron Tonverk preset files (*.tvpst). Supports all three generator machines: Multi + * (multi-sample), One-Shot (single sample) and Drum (a kit of up to several voices). The amplitude + * envelope, the filter and its envelope, sample loops, gain and panning are read into the model. The + * remaining, synth-specific parameters (arpeggiator, FX, global LFOs, modulation matrix) have no + * representation in the multi-sample model and are therefore not converted. + * + * @author Jürgen Moßgraber + */ +public class TonverkPresetDetector extends AbstractDetector +{ + /** The number of poles of the Tonverk multi-mode filter (12 dB/octave). */ + private static final int FILTER_POLES = 2; + /** The MIDI note the One-Shot machine is centered on. */ + private static final int ONESHOT_ROOT_NOTE = 60; + /** The mount point under which the device stores absolute sample paths. */ + private static final String DEVICE_MOUNT_PREFIX = "/mnt/sdcard/"; + + + /** + * Constructor. + * + * @param notifier The notifier + */ + public TonverkPresetDetector (final INotifier notifier) + { + super ("Elektron Tonverk Preset", "Tonverk", notifier, new MetadataSettingsUI ("Tonverk"), ".tvpst"); + } + + + /** {@inheritDoc} */ + @Override + protected List readPresetFile (final File file) + { + if (this.waitForDelivery ()) + return Collections.emptyList (); + + try + { + final TonverkPresetFile preset = new TonverkPresetFile (); + preset.parse (file.toPath ()); + + for (final String error: preset.errors) + this.notifier.logText (error); + + final IMultisampleSource source = this.convertPreset (file, preset); + return source == null ? Collections.emptyList () : Collections.singletonList (source); + } + catch (final IOException ex) + { + this.notifier.logError ("IDS_NOTIFY_ERR_LOAD_FILE", ex); + return Collections.emptyList (); + } + } + + + private IMultisampleSource convertPreset (final File file, final TonverkPresetFile preset) throws IOException + { + final String presetName = nameWithoutEnding (file); + final String [] parts = AudioFileUtils.createPathParts (file.getParentFile (), this.sourceFolder, file.getName ()); + final IMultisampleSource source = new DefaultMultisampleSource (file, parts, presetName); + + final List groups; + switch (preset.machine) + { + case MULTI -> groups = this.buildMultiGroups (file, preset); + case ONESHOT -> groups = this.buildOneShotGroups (file, preset); + case DRUM -> groups = this.buildDrumGroups (file, preset); + default -> + { + this.notifier.logError ("IDS_TONVERK_UNKNOWN_MACHINE", preset.param ("gen_machine")); + return null; + } + } + + if (groups.isEmpty ()) + return null; + source.setGroups (groups); + + // Metadata: derive from path/name first, then override with the explicit category and tags + final IMetadata metadata = source.getMetadata (); + final String [] tokens = Arrays.copyOf (parts, parts.length + 1); + tokens[tokens.length - 1] = presetName; + metadata.detectMetadata (this.settingsConfiguration, tokens); + if (preset.category != null && !preset.category.isBlank ()) + metadata.setCategory (preset.category); + if (!preset.tags.isEmpty ()) + metadata.setKeywords (preset.tags.toArray (new String [preset.tags.size ()])); + + return source; + } + + + /** + * Build the groups of a Multi machine: the key-zones are spread across key- and velocity-ranges + * (identical to the elmulti mapping), and the single, global generator envelope/filter is applied + * to every zone. + */ + private List buildMultiGroups (final File file, final TonverkPresetFile preset) throws IOException + { + final String prefix = Machine.MULTI.getParameterPrefix (); + final TreeMap>> orderedKeyRanges = new TreeMap<> (); + + for (final TonverkKeyZone keyZone: preset.keyZones) + { + final TreeMap> velocityMap = orderedKeyRanges.computeIfAbsent (Integer.valueOf (keyZone.pitch), _ -> new TreeMap<> ()); + for (final TonverkVelocityLayer velocityLayer: keyZone.velocityLayers) + { + final List zones = new ArrayList<> (); + for (final TonverkSampleSlot slot: velocityLayer.sampleSlots) + { + final ISampleZone zone = this.createMappedZone (file, slot, keyZone.pitch, keyZone.keyCenter); + if (zone == null) + continue; + this.applyAmplitudeEnvelope (zone, preset, prefix); + this.applyFilter (zone, preset, prefix); + this.applyGainAndPanning (zone, preset, prefix); + zones.add (zone); + } + if (zones.isEmpty ()) + continue; + if (zones.size () > 1) + for (int i = 0; i < zones.size (); i++) + zones.get (i).setSequencePosition (1 + i); + final int velocity = (int) Math.clamp (velocityLayer.velocity * 127.0, 0, 127.0); + velocityMap.put (Integer.valueOf (velocity), zones); + } + } + + if (orderedKeyRanges.values ().stream ().allMatch (TreeMap::isEmpty)) + return Collections.emptyList (); + TonverkMultiDetector.calculateRanges (orderedKeyRanges); + return TonverkMultiDetector.collapseToGroups (orderedKeyRanges); + } + + + /** + * Build the single group of a One-Shot machine: one sample mapped across the whole keyboard. The + * sample start/end and loop points are stored normalized [0..1] and are scaled by the number of + * sample frames. + */ + private List buildOneShotGroups (final File file, final TonverkPresetFile preset) throws IOException + { + final String prefix = Machine.ONESHOT.getParameterPrefix (); + final File sampleFile = this.resolveSample (file, preset.param (prefix + "_sample_slot")); + if (sampleFile == null) + { + this.notifier.logError ("IDS_TONVERK_SAMPLE_NOT_FOUND", preset.param (prefix + "_sample_slot")); + return Collections.emptyList (); + } + + final ISampleZone zone = this.createSampleZone (sampleFile); + zone.setKeyRoot (ONESHOT_ROOT_NOTE); + zone.setKeyLow (0); + zone.setKeyHigh (127); + zone.setVelocityLow (0); + zone.setVelocityHigh (127); + + final int frames = zone.getSampleData ().getAudioMetadata ().getNumberOfSamples (); + if (frames > 0) + { + final double startNormalized = preset.paramDouble (prefix + "_sample_start", 0); + final double endNormalized = preset.paramDouble (prefix + "_sample_end", 1); + zone.setStart ((int) Math.round (TonverkValues.clampNormalized (startNormalized) * frames)); + zone.setStop ((int) Math.round (TonverkValues.clampNormalized (endNormalized) * frames)); + + final double loopStartNormalized = preset.paramDouble (prefix + "_loop_start", 0); + final double loopEndNormalized = preset.paramDouble (prefix + "_loop_end", 0); + // Only create a loop if it covers a real sub-region (the device stores loop points even + // when looping is disabled). + if (loopEndNormalized > loopStartNormalized && (loopStartNormalized > 0.0001 || loopEndNormalized < 0.9999)) + { + final ISampleLoop loop = new DefaultSampleLoop (); + loop.setType (LoopType.FORWARDS); + loop.setStart ((int) Math.round (loopStartNormalized * frames)); + loop.setEnd ((int) Math.round (loopEndNormalized * frames)); + final double crossfade = preset.paramDouble (prefix + "_loop_xfade", 0); + if (crossfade > 0) + loop.setCrossfade (TonverkValues.clampNormalized (crossfade)); + zone.getLoops ().add (loop); + } + } + + this.applyAmplitudeEnvelope (zone, preset, prefix); + this.applyFilter (zone, preset, prefix); + this.applyGainAndPanning (zone, preset, prefix); + + final IGroup group = new DefaultGroup (); + group.addSampleZone (zone); + return List.of (group); + } + + + /** + * Build the single group of a Drum machine: each key-zone maps one drum sample to a single key. + * The Nth key-zone is played by the Nth drum voice, so the per-voice envelope, filter, gain and + * panning are applied accordingly. + */ + private List buildDrumGroups (final File file, final TonverkPresetFile preset) throws IOException + { + final IGroup group = new DefaultGroup (); + int voiceIndex = 0; + for (final TonverkKeyZone keyZone: preset.keyZones) + { + final String voicePrefix = Machine.DRUM.getParameterPrefix () + "_voice" + voiceIndex; + for (final TonverkVelocityLayer velocityLayer: keyZone.velocityLayers) + for (final TonverkSampleSlot slot: velocityLayer.sampleSlots) + { + final ISampleZone zone = this.createMappedZone (file, slot, keyZone.pitch, keyZone.keyCenter); + if (zone == null) + continue; + zone.setKeyLow (keyZone.pitch); + zone.setKeyHigh (keyZone.pitch); + zone.setVelocityLow (0); + zone.setVelocityHigh (127); + this.applyAmplitudeEnvelope (zone, preset, voicePrefix); + this.applyFilter (zone, preset, voicePrefix); + this.applyGainAndPanning (zone, preset, voicePrefix); + group.addSampleZone (zone); + } + voiceIndex++; + } + return group.getSampleZones ().isEmpty () ? Collections.emptyList () : List.of (group); + } + + + /** + * Create a sample zone from a mapping-slot sample: resolves the (absolute) sample path, sets the + * root note, tuning, trim and an (absolute, in samples) loop. + */ + private ISampleZone createMappedZone (final File file, final TonverkSampleSlot slot, final int pitch, final double keyCenter) throws IOException + { + final File sampleFile = this.resolveSample (file, slot.sample); + if (sampleFile == null) + { + this.notifier.logError ("IDS_TONVERK_SAMPLE_NOT_FOUND", slot.sample); + return null; + } + + final ISampleZone zone = this.createSampleZone (sampleFile); + zone.setKeyRoot (pitch); + zone.setTuning (pitch - keyCenter); + + if (slot.trimStart != null && slot.trimStart.intValue () >= 0) + zone.setStart (slot.trimStart.intValue ()); + if (slot.trimEnd != null && slot.trimEnd.intValue () >= 0) + zone.setStop (slot.trimEnd.intValue ()); + + if ("Forward".equals (slot.loopMode)) + { + final ISampleLoop loop = new DefaultSampleLoop (); + loop.setType (LoopType.FORWARDS); + if (slot.loopStart != null && slot.loopStart.intValue () >= 0) + loop.setStart (slot.loopStart.intValue ()); + if (slot.loopEnd != null && slot.loopEnd.intValue () >= 0) + loop.setEnd (slot.loopEnd.intValue ()); + if (slot.loopCrossfade != null && slot.loopCrossfade.intValue () >= 0) + loop.setCrossfadeInSamples (slot.loopCrossfade.intValue ()); + zone.getLoops ().add (loop); + } + + return zone; + } + + + /** + * Apply a Tonverk AHDSR amplitude envelope (parameters <prefix>_amp_env_*) to a zone. + * + * @param zone The zone + * @param preset The preset + * @param prefix The parameter prefix ('gen_multi', 'gen_oneshot' or 'gen_drum_voiceN') + */ + private void applyAmplitudeEnvelope (final ISampleZone zone, final TonverkPresetFile preset, final String prefix) + { + // The amplitude envelope is either ADSR (amp_mode == 2) or AHD (otherwise). In AHD mode the + // hold phase is active and the decay runs all the way to zero, so there is neither a sustain + // level nor a separate release phase. + final boolean adsr = preset.paramInt (prefix + "_amp_mode", 2) == 2; + final IEnvelope envelope = zone.getAmplitudeEnvelopeModulator ().getSource (); + envelope.setStartLevel (0); + envelope.setAttackTime (TonverkValues.normalizedToAttackTime (preset.paramDouble (prefix + "_amp_env_attack", 0))); + envelope.setHoldLevel (1.0); + envelope.setHoldTime (adsr ? 0 : TonverkValues.normalizedToHoldTime (preset.paramDouble (prefix + "_amp_env_hold", 0))); + envelope.setDecayTime (TonverkValues.normalizedToDecayTime (preset.paramDouble (prefix + "_amp_env_decay", 0))); + envelope.setSustainLevel (adsr ? TonverkValues.clampNormalized (preset.paramDouble (prefix + "_amp_env_sustain", 1)) : 0); + envelope.setReleaseTime (adsr ? TonverkValues.normalizedToReleaseTime (preset.paramDouble (prefix + "_amp_env_release", 0)) : 0); + envelope.setEndLevel (0); + } + + + /** + * Apply the Tonverk filter and its DADSR envelope (parameters <prefix>_filter_*) to a zone. + * + * @param zone The zone + * @param preset The preset + * @param prefix The parameter prefix ('gen_multi', 'gen_oneshot' or 'gen_drum_voiceN') + */ + private void applyFilter (final ISampleZone zone, final TonverkPresetFile preset, final String prefix) + { + final double cutoff = TonverkValues.normalizedToCutoff (preset.paramDouble (prefix + "_filter_frequency", 1.0)); + final double resonance = TonverkValues.clampNormalized (preset.paramDouble (prefix + "_filter_resonance", 0)); + final FilterType type = mapFilterType (preset.paramDouble (prefix + "_filter_type", 0)); + + final IFilter filter = new DefaultFilter (type, FILTER_POLES, cutoff, resonance); + + final IEnvelopeModulator cutoffModulator = filter.getCutoffEnvelopeModulator (); + final IEnvelope filterEnvelope = cutoffModulator.getSource (); + filterEnvelope.setDelayTime (TonverkValues.normalizedToDelayTime (preset.paramDouble (prefix + "_filter_env_delay", 0))); + filterEnvelope.setAttackTime (TonverkValues.normalizedToAttackTime (preset.paramDouble (prefix + "_filter_env_attack", 0))); + filterEnvelope.setDecayTime (TonverkValues.normalizedToDecayTime (preset.paramDouble (prefix + "_filter_env_decay", 0))); + filterEnvelope.setSustainLevel (TonverkValues.clampNormalized (preset.paramDouble (prefix + "_filter_env_sustain", 0))); + filterEnvelope.setReleaseTime (TonverkValues.normalizedToReleaseTime (preset.paramDouble (prefix + "_filter_env_release", 0))); + // The depth is stored bipolar with 0.5 as the center (no modulation); map it to [-1..1]. + cutoffModulator.setDepth (Math.clamp ((preset.paramDouble (prefix + "_filter_env_depth", 0.5) - 0.5) * 2.0, -1.0, 1.0)); + + zone.setFilter (filter); + } + + + /** + * Apply the generator volume (as gain in dB) and panning to a zone. + * + * @param zone The zone + * @param preset The preset + * @param prefix The parameter prefix ('gen_multi', 'gen_oneshot' or 'gen_drum_voiceN') + */ + private void applyGainAndPanning (final ISampleZone zone, final TonverkPresetFile preset, final String prefix) + { + final double volume = preset.paramDouble (prefix + "_volume", 1.0); + zone.setGain (volume <= 0 ? Double.NEGATIVE_INFINITY : 20.0 * Math.log10 (volume)); + final double panning = preset.paramDouble (prefix + "_pan", 0.5); + zone.setPanning (Math.clamp ((panning - 0.5) * 2.0, -1.0, 1.0)); + } + + + /** + * Resolve a sample referenced by its absolute device path (e.g. '/mnt/sdcard/...') to a file on + * the local file system. The device mount prefix is stripped and the remaining relative path is + * resolved against the preset's folder and its parent folders. + * + * @param file The preset file + * @param devicePath The absolute device path of the sample + * @return The resolved file or null if it could not be found + */ + private File resolveSample (final File file, final String devicePath) + { + if (devicePath == null || devicePath.isBlank ()) + return null; + + final File asIs = new File (devicePath); + if (asIs.exists ()) + return asIs; + + String relative = devicePath.replace ('\\', '/'); + final int mountIndex = relative.indexOf (DEVICE_MOUNT_PREFIX); + if (mountIndex >= 0) + relative = relative.substring (mountIndex + DEVICE_MOUNT_PREFIX.length ()); + else if (relative.startsWith ("/")) + relative = relative.substring (1); + + // Resolve the full relative path against the preset folder and all of its ancestors + for (File directory = file.getParentFile (); directory != null; directory = directory.getParentFile ()) + { + final File candidate = new File (directory, relative); + if (candidate.exists ()) + return candidate; + } + + // Fall back to dropping leading path segments (in case the mount maps deeper into the tree) + final String [] segments = relative.split ("/"); + for (int start = 1; start < segments.length; start++) + { + final String tail = String.join ("/", Arrays.copyOfRange (segments, start, segments.length)); + for (File directory = file.getParentFile (); directory != null; directory = directory.getParentFile ()) + { + final File candidate = new File (directory, tail); + if (candidate.exists ()) + return candidate; + } + } + + return null; + } + + + private static FilterType mapFilterType (final double morph) + { + // The Tonverk Multimode filter morphs continuously from low-pass through band-pass to + // high-pass; map the morph position to the closest discrete filter type of the model. + if (morph < 1.0 / 3.0) + return FilterType.LOW_PASS; + if (morph < 2.0 / 3.0) + return FilterType.BAND_PASS; + return FilterType.HIGH_PASS; + } + + + private static String nameWithoutEnding (final File file) + { + final String name = file.getName (); + final int dotIndex = name.lastIndexOf ('.'); + return dotIndex > 0 ? name.substring (0, dotIndex) : name; + } +} diff --git a/src/main/java/de/mossgrabers/convertwithmoss/format/elektron/TonverkPresetFile.java b/src/main/java/de/mossgrabers/convertwithmoss/format/elektron/TonverkPresetFile.java new file mode 100644 index 00000000..aff25e15 --- /dev/null +++ b/src/main/java/de/mossgrabers/convertwithmoss/format/elektron/TonverkPresetFile.java @@ -0,0 +1,505 @@ +// 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.elektron; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import de.mossgrabers.convertwithmoss.format.elektron.TonverkMultiFile.TonverkKeyZone; +import de.mossgrabers.convertwithmoss.format.elektron.TonverkMultiFile.TonverkSampleSlot; +import de.mossgrabers.convertwithmoss.format.elektron.TonverkMultiFile.TonverkVelocityLayer; + + +/** + * Reads and writes Elektron Tonverk preset files (*.tvpst). In contrast to the elmulti/eldrum + * mapping files, a preset is a full sound: it adds a flat [parameters] block (envelopes, + * filter, LFOs, FX, arpeggiator, ...) and embeds the sample mapping as a nested + * *_mapping_slot table. The file is a small sub-set of TOML (single-quoted scalar + * values, arrays of tables for the key-zones). + * + * @author Jürgen Moßgraber + */ +public class TonverkPresetFile +{ + /** The generator (sound engine) machine of a Tonverk preset. */ + public enum Machine + { + /** One-Shot machine: a single sample mapped across the whole keyboard. */ + ONESHOT ("gen_oneshot"), + /** Multi machine: a multi-sample mapped to key- and velocity-ranges. */ + MULTI ("gen_multi"), + /** Drum machine: a kit of up to several drum voices. */ + DRUM ("gen_drum"), + /** Unknown/unsupported machine. */ + UNKNOWN (""); + + + private final String parameterPrefix; + + + private Machine (final String parameterPrefix) + { + this.parameterPrefix = parameterPrefix; + } + + + /** + * Get the prefix of the parameter names belonging to this machine (e.g. 'gen_multi'). + * + * @return The parameter prefix + */ + public String getParameterPrefix () + { + return this.parameterPrefix; + } + + + /** + * Map the value of the 'gen_machine' parameter to a machine. + * + * @param value The 'gen_machine' value ('0', '1' or '2') + * @return The machine, {@link #UNKNOWN} if it could not be mapped + */ + public static Machine fromGenMachine (final String value) + { + if (value == null) + return UNKNOWN; + return switch (value.trim ()) + { + case "0" -> ONESHOT; + case "1" -> MULTI; + case "2" -> DRUM; + default -> UNKNOWN; + }; + } + } + + + /** Format version of the preset (the top-level 'version'). */ + public int version = 2; + /** The preset category (e.g. 'KEYS', 'DRUMS'). */ + public String category = ""; + /** The preset tags. */ + public final List tags = new ArrayList<> (); + /** All entries of the flat '[parameters]' block, in file order, values without quotes. */ + public final Map parameters = new LinkedHashMap<> (); + /** The machine derived from the 'gen_machine' parameter (set after {@link #parse(Path)}). */ + public Machine machine = Machine.UNKNOWN; + /** The display name stored in the mapping slot. */ + public String mappingSlotName = ""; + /** The version of the mapping slot. */ + public int mappingSlotVersion = 0; + /** The key-zones of the mapping slot (Multi and Drum machines). */ + public final List keyZones = new ArrayList<> (); + /** Errors which occurred during parsing. */ + public final List errors = new ArrayList<> (); + + + /** + * Parses a Tonverk preset file. + * + * @param path The path to the file to parse + * @throws IOException Could not read/parse the file + */ + public void parse (final Path path) throws IOException + { + this.parse (Files.readAllLines (path)); + } + + + /** + * Parses the lines of a Tonverk preset file (or a template). + * + * @param lines The lines to parse + */ + public void parse (final List lines) + { + this.errors.clear (); + + boolean inParameters = false; + boolean inMappingSlotRoot = false; + TonverkKeyZone currentZone = null; + TonverkVelocityLayer currentLayer = null; + TonverkSampleSlot currentSlot = null; + + for (int i = 0; i < lines.size (); i++) + { + final String line = lines.get (i).trim (); + if (line.isEmpty () || line.startsWith ("#")) + continue; + + // An array of tables, e.g. '[[parameters.gen_multi_mapping_slot.key-zones]]' + if (line.startsWith ("[[")) + { + final String section = stripSectionBrackets (line); + if (section.endsWith (".sample-slots")) + { + if (currentLayer == null) + this.errors.add ("sample-slot without velocity-layer"); + else + { + currentSlot = new TonverkSampleSlot (); + currentLayer.sampleSlots.add (currentSlot); + } + } + else if (section.endsWith (".velocity-layers")) + { + if (currentZone == null) + this.errors.add ("velocity-layer without key-zone"); + else + { + currentLayer = new TonverkVelocityLayer (); + currentZone.velocityLayers.add (currentLayer); + currentSlot = null; + } + } + else if (section.endsWith (".key-zones")) + { + currentZone = new TonverkKeyZone (); + this.keyZones.add (currentZone); + currentLayer = null; + currentSlot = null; + } + else + this.errors.add ("Unknown array section: " + section); + inMappingSlotRoot = false; + continue; + } + + // A table, e.g. '[parameters]' or '[parameters.gen_multi_mapping_slot]' + if (line.startsWith ("[")) + { + final String section = stripSectionBrackets (line); + if (section.equals ("parameters")) + { + inParameters = true; + inMappingSlotRoot = false; + } + else if (section.startsWith ("parameters.") && section.endsWith ("_mapping_slot")) + { + inMappingSlotRoot = true; + currentZone = null; + currentLayer = null; + currentSlot = null; + } + else + this.errors.add ("Unknown section: " + section); + continue; + } + + // A key/value pair + final int eq = line.indexOf ('='); + if (eq < 0) + { + this.errors.add ("Invalid line: " + line); + continue; + } + final String key = line.substring (0, eq).trim (); + final String rawValue = line.substring (eq + 1).trim (); + + // An array value, either inline ('[]' or "[ 'a', 'b' ]") or multi-line ('[' followed by + // the items on the next lines up to a closing ']'). + if (rawValue.startsWith ("[")) + { + final List items = new ArrayList<> (); + if (rawValue.equals ("[")) + { + while (i + 1 < lines.size ()) + { + final String arrayLine = lines.get (++i).trim (); + if (arrayLine.equals ("]")) + break; + final String item = stripQuotes (arrayLine.endsWith (",") ? arrayLine.substring (0, arrayLine.length () - 1) : arrayLine); + if (!item.isEmpty ()) + items.add (item); + } + } + else + { + String inline = rawValue.substring (1); + if (inline.endsWith ("]")) + inline = inline.substring (0, inline.length () - 1); + for (final String part: inline.split (",")) + { + final String item = stripQuotes (part.trim ()); + if (!item.isEmpty ()) + items.add (item); + } + } + if ("tags".equals (key)) + this.tags.addAll (items); + continue; + } + + final String value = stripQuotes (rawValue); + + if (currentSlot != null) + this.assignSampleSlot (currentSlot, key, value); + else if (currentLayer != null) + this.assignVelocityLayer (currentLayer, key, value); + else if (currentZone != null) + this.assignKeyZone (currentZone, key, value); + else if (inMappingSlotRoot) + { + if ("name".equals (key)) + this.mappingSlotName = value; + else if ("version".equals (key)) + this.mappingSlotVersion = this.parseIntSafe (value, 0); + } + else if (inParameters) + this.parameters.put (key, value); + else + switch (key) + { + case "version" -> this.version = this.parseIntSafe (value, 2); + case "category" -> this.category = value; + default -> this.errors.add ("Unknown root tag: " + key); + } + } + + this.machine = Machine.fromGenMachine (this.parameters.get ("gen_machine")); + } + + + /** + * Writes a Tonverk preset file. + * + * @param path The path to write to + * @throws IOException Could not write the file + */ + public void write (final Path path) throws IOException + { + final List out = new ArrayList<> (); + out.add ("version = " + this.version); + if (this.category != null && !this.category.isEmpty ()) + out.add ("category = " + quote (this.category)); + if (!this.tags.isEmpty ()) + { + out.add ("tags = ["); + for (final String tag: this.tags) + out.add (" " + quote (tag) + ","); + out.add ("]"); + } + + out.add (""); + out.add ("[parameters]"); + for (final Map.Entry entry: this.parameters.entrySet ()) + out.add (entry.getKey () + " = " + quote (entry.getValue ())); + + // The mapping slot is written last (matches the device's file layout). One-Shot presets + // carry their single sample in the parameters block and have no mapping slot. + if (!this.keyZones.isEmpty () && this.machine != Machine.UNKNOWN && this.machine != Machine.ONESHOT) + { + final String prefix = "parameters." + this.machine.getParameterPrefix () + "_mapping_slot"; + out.add (""); + out.add ("[" + prefix + "]"); + out.add ("version = " + this.mappingSlotVersion); + out.add ("name = " + quote (this.mappingSlotName)); + + for (final TonverkKeyZone zone: this.keyZones) + { + out.add (""); + out.add ("[[" + prefix + ".key-zones]]"); + out.add ("pitch = " + zone.pitch); + out.add ("key-center = " + formatNumber (zone.keyCenter)); + + for (final TonverkVelocityLayer layer: zone.velocityLayers) + { + out.add (""); + out.add ("[[" + prefix + ".key-zones.velocity-layers]]"); + out.add ("velocity = " + formatNumber (layer.velocity)); + if (layer.strategy != null) + out.add ("strategy = " + quote (layer.strategy)); + + for (final TonverkSampleSlot slot: layer.sampleSlots) + { + out.add (""); + out.add ("[[" + prefix + ".key-zones.velocity-layers.sample-slots]]"); + out.add ("sample = " + quote (slot.sample)); + if (slot.loopMode != null) + out.add ("loop-mode = " + quote (slot.loopMode)); + if ("Forward".equals (slot.loopMode)) + { + if (slot.loopStart != null && slot.loopStart.intValue () >= 0) + out.add ("loop-start = " + slot.loopStart); + if (slot.loopEnd != null && slot.loopEnd.intValue () >= 0) + out.add ("loop-end = " + slot.loopEnd); + if (slot.loopCrossfade != null && slot.loopCrossfade.intValue () >= 0) + out.add ("loop-crossfade = " + slot.loopCrossfade); + if (slot.keepLoopingOnRelease != null && slot.keepLoopingOnRelease.booleanValue ()) + out.add ("keep-looping-on-release = true"); + } + } + } + } + } + + Files.write (path, out); + } + + + /** + * Get a parameter value as text. + * + * @param key The parameter name + * @return The value or null if not present + */ + public String param (final String key) + { + return this.parameters.get (key); + } + + + /** + * Get a parameter value as a floating point number. + * + * @param key The parameter name + * @param defaultValue The value to return if the parameter is missing or not a number + * @return The value + */ + public double paramDouble (final String key, final double defaultValue) + { + final String value = this.parameters.get (key); + if (value == null || value.isBlank ()) + return defaultValue; + try + { + return Double.parseDouble (value); + } + catch (final NumberFormatException ex) + { + return defaultValue; + } + } + + + /** + * Get a parameter value as an integer number. + * + * @param key The parameter name + * @param defaultValue The value to return if the parameter is missing or not a number + * @return The value + */ + public int paramInt (final String key, final int defaultValue) + { + final String value = this.parameters.get (key); + if (value == null || value.isBlank ()) + return defaultValue; + try + { + return (int) Math.round (Double.parseDouble (value)); + } + catch (final NumberFormatException ex) + { + return defaultValue; + } + } + + + private void assignKeyZone (final TonverkKeyZone keyZone, final String key, final String value) + { + switch (key) + { + case "pitch" -> keyZone.pitch = this.parseIntSafe (value, 0); + case "key-center" -> keyZone.keyCenter = this.parseDoubleSafe (value, 0); + default -> this.errors.add ("Unknown key-zone tag: " + key); + } + } + + + private void assignVelocityLayer (final TonverkVelocityLayer velocityLayer, final String key, final String value) + { + switch (key) + { + case "velocity" -> velocityLayer.velocity = this.parseDoubleSafe (value, 0); + case "strategy" -> velocityLayer.strategy = value; + default -> this.errors.add ("Unknown velocity-layer tag: " + key); + } + } + + + private void assignSampleSlot (final TonverkSampleSlot sampleSlot, final String key, final String value) + { + switch (key) + { + case "sample" -> sampleSlot.sample = value; + case "loop-mode" -> sampleSlot.loopMode = value; + case "loop-start" -> sampleSlot.loopStart = Integer.valueOf (this.parseIntSafe (value, 0)); + case "loop-end" -> sampleSlot.loopEnd = Integer.valueOf (this.parseIntSafe (value, 0)); + case "loop-crossfade" -> sampleSlot.loopCrossfade = Integer.valueOf (this.parseIntSafe (value, 0)); + case "keep-looping-on-release" -> sampleSlot.keepLoopingOnRelease = Boolean.valueOf (value); + case "trim-start" -> sampleSlot.trimStart = Integer.valueOf (this.parseIntSafe (value, 0)); + case "trim-end" -> sampleSlot.trimEnd = Integer.valueOf (this.parseIntSafe (value, 0)); + default -> this.errors.add ("Unknown sample-slot tag: " + key); + } + } + + + private int parseIntSafe (final String value, final int defaultValue) + { + try + { + return (int) Math.round (Double.parseDouble (value.trim ())); + } + catch (final NumberFormatException ex) + { + this.errors.add ("Not an integer: " + value); + return defaultValue; + } + } + + + private double parseDoubleSafe (final String value, final double defaultValue) + { + try + { + return Double.parseDouble (value.trim ()); + } + catch (final NumberFormatException ex) + { + this.errors.add ("Not a number: " + value); + return defaultValue; + } + } + + + private static String stripSectionBrackets (final String line) + { + String result = line.trim (); + while (result.startsWith ("[")) + result = result.substring (1); + while (result.endsWith ("]")) + result = result.substring (0, result.length () - 1); + return result.trim (); + } + + + private static String stripQuotes (final String value) + { + final String trimmed = value.trim (); + if (trimmed.length () >= 2 && trimmed.startsWith ("'") && trimmed.endsWith ("'")) + return trimmed.substring (1, trimmed.length () - 1); + return trimmed; + } + + + private static String quote (final String value) + { + return "'" + (value == null ? "" : value) + "'"; + } + + + private static String formatNumber (final double value) + { + if (value == Math.rint (value) && !Double.isInfinite (value)) + return Long.toString ((long) value) + ".0"; + return Double.toString (value); + } +} diff --git a/src/main/java/de/mossgrabers/convertwithmoss/format/elektron/TonverkValues.java b/src/main/java/de/mossgrabers/convertwithmoss/format/elektron/TonverkValues.java new file mode 100644 index 00000000..d58a5c33 --- /dev/null +++ b/src/main/java/de/mossgrabers/convertwithmoss/format/elektron/TonverkValues.java @@ -0,0 +1,226 @@ +// 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.elektron; + +/** + * Conversions between the normalized parameter values stored in a Tonverk preset (most continuous + * parameters are stored as a floating point number in the range [0..1]) and the physical units used + * by the ConvertWithMoss model (envelope times in seconds, filter cut-off in Hertz). + *

+ * The Tonverk firmware uses internal, non-published curves to map these normalized values to times + * and frequencies. Since those curves are not contained in the preset files, the mappings below are + * documented approximations: a power curve for times and an exponential curve for the filter cut-off. + * They are chosen so that a Tonverk-to-Tonverk round-trip is loss-less (the inverse functions are + * exact), while a conversion to/from a unit-based format (e.g. seconds) is a close approximation. All + * range constants are gathered here so they can be tuned in one place. + * + * @author Jürgen Moßgraber + */ +public final class TonverkValues +{ + /** Maximum attack time in seconds (normalized value 1.0). */ + private static final double ATTACK_MAX_SECONDS = 8.0; + /** Maximum hold time in seconds (normalized value 1.0). */ + private static final double HOLD_MAX_SECONDS = 8.0; + /** Maximum decay time in seconds (normalized value 1.0). */ + private static final double DECAY_MAX_SECONDS = 24.0; + /** Maximum release time in seconds (normalized value 1.0). */ + private static final double RELEASE_MAX_SECONDS = 24.0; + /** Maximum delay time in seconds (normalized value 1.0). */ + private static final double DELAY_MAX_SECONDS = 4.0; + /** + * The exponent of the power curve used for all envelope times. A value > 1 gives finer control + * for short times. seconds = max * normalized^curve. + */ + private static final double TIME_CURVE = 3.0; + + /** Minimum filter cut-off frequency in Hertz (normalized value 0.0). */ + private static final double MIN_CUTOFF_HZ = 20.0; + /** Maximum filter cut-off frequency in Hertz (normalized value 1.0). */ + private static final double MAX_CUTOFF_HZ = 20000.0; + + + private TonverkValues () + { + // Utility class + } + + + /** + * Convert a normalized envelope delay value to seconds. + * + * @param normalized The normalized value [0..1] + * @return The time in seconds + */ + public static double normalizedToDelayTime (final double normalized) + { + return normalizedToTime (normalized, DELAY_MAX_SECONDS); + } + + + /** + * Convert an envelope delay time in seconds to a normalized value. + * + * @param seconds The time in seconds + * @return The normalized value [0..1] + */ + public static double delayTimeToNormalized (final double seconds) + { + return timeToNormalized (seconds, DELAY_MAX_SECONDS); + } + + + /** + * Convert a normalized envelope attack value to seconds. + * + * @param normalized The normalized value [0..1] + * @return The time in seconds + */ + public static double normalizedToAttackTime (final double normalized) + { + return normalizedToTime (normalized, ATTACK_MAX_SECONDS); + } + + + /** + * Convert an envelope attack time in seconds to a normalized value. + * + * @param seconds The time in seconds + * @return The normalized value [0..1] + */ + public static double attackTimeToNormalized (final double seconds) + { + return timeToNormalized (seconds, ATTACK_MAX_SECONDS); + } + + + /** + * Convert a normalized envelope hold value to seconds. + * + * @param normalized The normalized value [0..1] + * @return The time in seconds + */ + public static double normalizedToHoldTime (final double normalized) + { + return normalizedToTime (normalized, HOLD_MAX_SECONDS); + } + + + /** + * Convert an envelope hold time in seconds to a normalized value. + * + * @param seconds The time in seconds + * @return The normalized value [0..1] + */ + public static double holdTimeToNormalized (final double seconds) + { + return timeToNormalized (seconds, HOLD_MAX_SECONDS); + } + + + /** + * Convert a normalized envelope decay value to seconds. + * + * @param normalized The normalized value [0..1] + * @return The time in seconds + */ + public static double normalizedToDecayTime (final double normalized) + { + return normalizedToTime (normalized, DECAY_MAX_SECONDS); + } + + + /** + * Convert an envelope decay time in seconds to a normalized value. + * + * @param seconds The time in seconds + * @return The normalized value [0..1] + */ + public static double decayTimeToNormalized (final double seconds) + { + return timeToNormalized (seconds, DECAY_MAX_SECONDS); + } + + + /** + * Convert a normalized envelope release value to seconds. + * + * @param normalized The normalized value [0..1] + * @return The time in seconds + */ + public static double normalizedToReleaseTime (final double normalized) + { + return normalizedToTime (normalized, RELEASE_MAX_SECONDS); + } + + + /** + * Convert an envelope release time in seconds to a normalized value. + * + * @param seconds The time in seconds + * @return The normalized value [0..1] + */ + public static double releaseTimeToNormalized (final double seconds) + { + return timeToNormalized (seconds, RELEASE_MAX_SECONDS); + } + + + /** + * Convert a normalized filter cut-off value to a frequency in Hertz. + * + * @param normalized The normalized value [0..1] + * @return The frequency in Hertz + */ + public static double normalizedToCutoff (final double normalized) + { + final double clamped = clampNormalized (normalized); + return MIN_CUTOFF_HZ * Math.pow (MAX_CUTOFF_HZ / MIN_CUTOFF_HZ, clamped); + } + + + /** + * Convert a filter cut-off frequency in Hertz to a normalized value. + * + * @param hertz The frequency in Hertz + * @return The normalized value [0..1] + */ + public static double cutoffToNormalized (final double hertz) + { + if (hertz <= MIN_CUTOFF_HZ) + return 0; + if (hertz >= MAX_CUTOFF_HZ) + return 1; + return Math.log (hertz / MIN_CUTOFF_HZ) / Math.log (MAX_CUTOFF_HZ / MIN_CUTOFF_HZ); + } + + + /** + * Clamp a value to the normalized range [0..1]. + * + * @param normalized The value + * @return The clamped value + */ + public static double clampNormalized (final double normalized) + { + return Math.clamp (normalized, 0.0, 1.0); + } + + + private static double normalizedToTime (final double normalized, final double maxSeconds) + { + return maxSeconds * Math.pow (clampNormalized (normalized), TIME_CURVE); + } + + + private static double timeToNormalized (final double seconds, final double maxSeconds) + { + if (seconds <= 0) + return 0; + if (seconds >= maxSeconds) + return 1; + return Math.pow (seconds / maxSeconds, 1.0 / TIME_CURVE); + } +} diff --git a/src/main/resources/Strings.properties b/src/main/resources/Strings.properties index 1a207681..26f22914 100644 --- a/src/main/resources/Strings.properties +++ b/src/main/resources/Strings.properties @@ -549,6 +549,14 @@ IDS_DS_ADD_FILTER_TO_GROUPS=Add low-pass filter to all groups if none is present IDS_ELEKTRON_RESAMPLE=Compatibility IDS_ELEKTRON_CONVERT_TO_24_48=Re-sample to 24bit/48kHz +IDS_TONVERK_UNKNOWN_MACHINE=Unknown Tonverk generator machine '%1'. Only One-Shot, Multi and Drum are supported.\n +IDS_TONVERK_SAMPLE_NOT_FOUND=Could not find the sample referenced by the preset: %1\n +IDS_TONVERK_DRUM_LIMIT=The source has %1 drums but the Tonverk Drum machine only has %2 voices. The additional drums are dropped.\n +IDS_TONVERK_OUTPUT_ENGINE=Output Engine +IDS_TONVERK_ENGINE_MULTI=Multi-Sample +IDS_TONVERK_ENGINE_DRUM=Drum Kit +IDS_TONVERK_ENGINE_AUTO=Auto (from source) + IDS_KMP_OPTIONS=KMP Options IDS_KMP_USE_KSC=Use KSC files as the input (otherwise only KMP files are used) IDS_KMP_GAIN_12DB=Enable the +12dB option diff --git a/src/main/resources/de/mossgrabers/convertwithmoss/templates/tonverk/drum-template.tvpst b/src/main/resources/de/mossgrabers/convertwithmoss/templates/tonverk/drum-template.tvpst new file mode 100644 index 00000000..b3cd601f --- /dev/null +++ b/src/main/resources/de/mossgrabers/convertwithmoss/templates/tonverk/drum-template.tvpst @@ -0,0 +1,522 @@ +version = 2 +category = 'DRUMS' +tags = [] + +[parameters] +arp_enabled = '0' +arp_speed = '13' +arp_note_length = '14' +arp_mode = '0' +arp_range = '1' +lfo1_speed = '32' +lfo1_multiplier = '1' +lfo1_destination = '' +lfo1_depth = '0' +lfo1_waveform = 'Sine' +lfo1_start_phase = '0' +lfo1_trig_mode = 'Free' +lfo1_smoothing = '0' +lfo2_speed = '32' +lfo2_multiplier = '1' +lfo2_destination = '' +lfo2_depth = '0' +lfo2_waveform = 'Random' +lfo2_start_phase = '0' +lfo2_trig_mode = 'Hold' +lfo2_fade = '0' +lfo2_smoothing = '0' +pitchbend_value = '0' +pitchbend_mod1_destination = '' +pitchbend_mod2_destination = '' +pitchbend_mod3_destination = '' +pitchbend_mod4_destination = '' +pitchbend_mod1_depth = '0' +pitchbend_mod2_depth = '0' +pitchbend_mod3_depth = '0' +pitchbend_mod4_depth = '0' +aftertouch_value = '0' +aftertouch_mod1_destination = '' +aftertouch_mod2_destination = '' +aftertouch_mod3_destination = '' +aftertouch_mod4_destination = '' +aftertouch_mod1_depth = '0' +aftertouch_mod2_depth = '0' +aftertouch_mod3_depth = '0' +aftertouch_mod4_depth = '0' +modwheel_value = '0' +modwheel_mod1_destination = '' +modwheel_mod2_destination = '' +modwheel_mod3_destination = '' +modwheel_mod4_destination = '' +modwheel_mod1_depth = '0' +modwheel_mod2_depth = '0' +modwheel_mod3_depth = '0' +modwheel_mod4_depth = '0' +breath_control_value = '0' +breath_control_mod1_destination = '' +breath_control_mod2_destination = '' +breath_control_mod3_destination = '' +breath_control_mod4_destination = '' +breath_control_mod1_depth = '0' +breath_control_mod2_depth = '0' +breath_control_mod3_depth = '0' +breath_control_mod4_depth = '0' +fx1_machine = '0' +fx1_dirtshaper_drive = '0' +fx1_dirtshaper_rectify = '0.015748031' +fx1_dirtshaper_hpf = '0.503937' +fx1_dirtshaper_lpf = '1' +fx1_dirtshaper_xnoise = '0.1023622' +fx1_dirtshaper_noise_freq = '0.503937' +fx1_dirtshaper_noise_reso = '0' +fx1_dirtshaper_mix = '1' +fx2_machine = '0' +fx2_comb_frequency = '0.75' +fx2_comb_feedback = '0.6640625' +fx2_comb_damping = '1' +fx2_comb_mod_start_phase = '-0.0027855153' +fx2_comb_detune = '0.44085938' +fx2_comb_mod_rate = '0.27559054' +fx2_comb_mod_depth = '0.48818898' +fx2_comb_mix = '0.89' +midi_machine = '0' +gen_machine = '2' +gen_drum_note_priority = '0' +gen_drum_note_reuse_voices = '1' +gen_drum_velocity_curve = '3' +gen_drum_tune = '0.5' +gen_drum_gain = '1' +gen_drum_filter_routing = '5' +gen_drum_voice0_tune = '0.5' +gen_drum_voice0_play_mode = '1' +gen_drum_voice0_sample_start = '0' +gen_drum_voice0_play_length = '1' +gen_drum_voice0_loop_point = '-0.00008333333' +gen_drum_voice0_overdrive = '0.24409449' +gen_drum_voice0_base = '0' +gen_drum_voice0_width = '1' +gen_drum_voice0_filter_type = '0.9448819' +gen_drum_voice0_filter_frequency = '0.19669291' +gen_drum_voice0_filter_resonance = '0.53543305' +gen_drum_voice0_filter_env_delay = '0' +gen_drum_voice0_filter_env_attack = '0' +gen_drum_voice0_filter_env_decay = '0.42519686' +gen_drum_voice0_filter_env_sustain = '0' +gen_drum_voice0_filter_env_release = '0.503937' +gen_drum_voice0_filter_env_depth = '0.5' +gen_drum_voice0_filter_env_reset = '0' +gen_drum_voice0_stereo_spread = '0.5' +gen_drum_voice0_amp_mode = '1' +gen_drum_voice0_amp_env_attack = '0.031496063' +gen_drum_voice0_amp_env_hold = '0.2913386' +gen_drum_voice0_amp_env_decay = '0.31496063' +gen_drum_voice0_amp_env_sustain = '1' +gen_drum_voice0_amp_env_release = '0.2519685' +gen_drum_voice0_amp_env_reset = '1' +gen_drum_voice0_volume = '0.78740156' +gen_drum_voice0_pan = '0.5' +gen_drum_voice0_lfo1_speed = '0.75' +gen_drum_voice0_lfo1_multiplier = '1' +gen_drum_voice0_lfo1_fade = '0.5' +gen_drum_voice0_lfo1_wave = 'Sine' +gen_drum_voice0_lfo1_start_phase = '0' +gen_drum_voice0_lfo1_smoothing = '0' +gen_drum_voice0_lfo1_trig_mode = 'Free' +gen_drum_voice0_lfo1_destination = '0' +gen_drum_voice0_lfo1_depth = '0.5' +gen_drum_voice0_lfo2_speed = '0.75' +gen_drum_voice0_lfo2_multiplier = '3' +gen_drum_voice0_lfo2_fade = '0.5' +gen_drum_voice0_lfo2_wave = 'Triangle' +gen_drum_voice0_lfo2_start_phase = '0' +gen_drum_voice0_lfo2_smoothing = '0' +gen_drum_voice0_lfo2_trig_mode = 'Free' +gen_drum_voice0_lfo2_destination = '0' +gen_drum_voice0_lfo2_depth = '0.5' +gen_drum_voice0_mod_env_delay = '0' +gen_drum_voice0_mod_env_attack = '0' +gen_drum_voice0_mod_env_decay = '0.503937' +gen_drum_voice0_mod_env_sustain = '0' +gen_drum_voice0_mod_env_release = '0.503937' +gen_drum_voice0_mod_env_reset = '0' +gen_drum_voice0_mod_env_destination = '0' +gen_drum_voice0_mod_env_depth = '0.5' +gen_drum_voice1_tune = '0.5' +gen_drum_voice1_play_mode = '1' +gen_drum_voice1_sample_start = '0' +gen_drum_voice1_play_length = '1' +gen_drum_voice1_loop_point = '-0.00008333333' +gen_drum_voice1_overdrive = '0.08661418' +gen_drum_voice1_base = '0' +gen_drum_voice1_width = '1' +gen_drum_voice1_filter_type = '0' +gen_drum_voice1_filter_frequency = '0.91322833' +gen_drum_voice1_filter_resonance = '0' +gen_drum_voice1_filter_env_delay = '0' +gen_drum_voice1_filter_env_attack = '0' +gen_drum_voice1_filter_env_decay = '0.503937' +gen_drum_voice1_filter_env_sustain = '0' +gen_drum_voice1_filter_env_release = '0.503937' +gen_drum_voice1_filter_env_depth = '0.5' +gen_drum_voice1_filter_env_reset = '0' +gen_drum_voice1_stereo_spread = '0.5' +gen_drum_voice1_amp_mode = '1' +gen_drum_voice1_amp_env_attack = '0' +gen_drum_voice1_amp_env_hold = '0' +gen_drum_voice1_amp_env_decay = '1' +gen_drum_voice1_amp_env_sustain = '1' +gen_drum_voice1_amp_env_release = '0.2519685' +gen_drum_voice1_amp_env_reset = '1' +gen_drum_voice1_volume = '0.8267717' +gen_drum_voice1_pan = '0.5' +gen_drum_voice1_lfo1_speed = '0.75' +gen_drum_voice1_lfo1_multiplier = '1' +gen_drum_voice1_lfo1_fade = '0.5' +gen_drum_voice1_lfo1_wave = 'Random' +gen_drum_voice1_lfo1_start_phase = '0' +gen_drum_voice1_lfo1_smoothing = '0' +gen_drum_voice1_lfo1_trig_mode = 'Hold' +gen_drum_voice1_lfo1_destination = '0' +gen_drum_voice1_lfo1_depth = '0.50003904' +gen_drum_voice1_lfo2_speed = '0.75' +gen_drum_voice1_lfo2_multiplier = '3' +gen_drum_voice1_lfo2_fade = '0.5' +gen_drum_voice1_lfo2_wave = 'Triangle' +gen_drum_voice1_lfo2_start_phase = '0' +gen_drum_voice1_lfo2_smoothing = '0' +gen_drum_voice1_lfo2_trig_mode = 'Free' +gen_drum_voice1_lfo2_destination = '0' +gen_drum_voice1_lfo2_depth = '0.5' +gen_drum_voice1_mod_env_delay = '0' +gen_drum_voice1_mod_env_attack = '0' +gen_drum_voice1_mod_env_decay = '0.503937' +gen_drum_voice1_mod_env_sustain = '0' +gen_drum_voice1_mod_env_release = '0.503937' +gen_drum_voice1_mod_env_reset = '0' +gen_drum_voice1_mod_env_destination = '0' +gen_drum_voice1_mod_env_depth = '0.5' +gen_drum_voice2_tune = '0.5' +gen_drum_voice2_play_mode = '1' +gen_drum_voice2_sample_start = '0.00066666666' +gen_drum_voice2_play_length = '1' +gen_drum_voice2_loop_point = '-0.00008333333' +gen_drum_voice2_overdrive = '0.87401575' +gen_drum_voice2_base = '0.46456692' +gen_drum_voice2_width = '1' +gen_drum_voice2_filter_type = '0' +gen_drum_voice2_filter_frequency = '1' +gen_drum_voice2_filter_resonance = '0' +gen_drum_voice2_filter_env_delay = '0' +gen_drum_voice2_filter_env_attack = '0' +gen_drum_voice2_filter_env_decay = '0.503937' +gen_drum_voice2_filter_env_sustain = '0' +gen_drum_voice2_filter_env_release = '0.503937' +gen_drum_voice2_filter_env_depth = '0.5' +gen_drum_voice2_filter_env_reset = '0' +gen_drum_voice2_stereo_spread = '0.6796875' +gen_drum_voice2_amp_mode = '1' +gen_drum_voice2_amp_env_attack = '0' +gen_drum_voice2_amp_env_hold = '1' +gen_drum_voice2_amp_env_decay = '0.3464567' +gen_drum_voice2_amp_env_sustain = '1' +gen_drum_voice2_amp_env_release = '0.2519685' +gen_drum_voice2_amp_env_reset = '1' +gen_drum_voice2_volume = '0.72440946' +gen_drum_voice2_pan = '0.515625' +gen_drum_voice2_lfo1_speed = '0.75' +gen_drum_voice2_lfo1_multiplier = '1' +gen_drum_voice2_lfo1_fade = '0.5' +gen_drum_voice2_lfo1_wave = 'Sine' +gen_drum_voice2_lfo1_start_phase = '0' +gen_drum_voice2_lfo1_smoothing = '0' +gen_drum_voice2_lfo1_trig_mode = 'Free' +gen_drum_voice2_lfo1_destination = '0' +gen_drum_voice2_lfo1_depth = '0.5' +gen_drum_voice2_lfo2_speed = '0.75' +gen_drum_voice2_lfo2_multiplier = '3' +gen_drum_voice2_lfo2_fade = '0.5' +gen_drum_voice2_lfo2_wave = 'Triangle' +gen_drum_voice2_lfo2_start_phase = '0' +gen_drum_voice2_lfo2_smoothing = '0' +gen_drum_voice2_lfo2_trig_mode = 'Free' +gen_drum_voice2_lfo2_destination = '0' +gen_drum_voice2_lfo2_depth = '0.5' +gen_drum_voice2_mod_env_delay = '0' +gen_drum_voice2_mod_env_attack = '0' +gen_drum_voice2_mod_env_decay = '0.503937' +gen_drum_voice2_mod_env_sustain = '0' +gen_drum_voice2_mod_env_release = '0.503937' +gen_drum_voice2_mod_env_reset = '0' +gen_drum_voice2_mod_env_destination = '0' +gen_drum_voice2_mod_env_depth = '0.5' +gen_drum_voice3_tune = '0.5' +gen_drum_voice3_play_mode = '1' +gen_drum_voice3_sample_start = '0' +gen_drum_voice3_play_length = '1' +gen_drum_voice3_loop_point = '-0.00008333333' +gen_drum_voice3_overdrive = '0' +gen_drum_voice3_base = '0' +gen_drum_voice3_width = '1' +gen_drum_voice3_filter_type = '0' +gen_drum_voice3_filter_frequency = '1' +gen_drum_voice3_filter_resonance = '0' +gen_drum_voice3_filter_env_delay = '0' +gen_drum_voice3_filter_env_attack = '0' +gen_drum_voice3_filter_env_decay = '0.503937' +gen_drum_voice3_filter_env_sustain = '0' +gen_drum_voice3_filter_env_release = '0.503937' +gen_drum_voice3_filter_env_depth = '0.5' +gen_drum_voice3_filter_env_reset = '0' +gen_drum_voice3_stereo_spread = '0.5' +gen_drum_voice3_amp_mode = '1' +gen_drum_voice3_amp_env_attack = '0' +gen_drum_voice3_amp_env_hold = '0.03937008' +gen_drum_voice3_amp_env_decay = '0.1496063' +gen_drum_voice3_amp_env_sustain = '1' +gen_drum_voice3_amp_env_release = '0.2519685' +gen_drum_voice3_amp_env_reset = '1' +gen_drum_voice3_volume = '0.86614174' +gen_drum_voice3_pan = '0.5' +gen_drum_voice3_lfo1_speed = '0.75' +gen_drum_voice3_lfo1_multiplier = '1' +gen_drum_voice3_lfo1_fade = '0.5' +gen_drum_voice3_lfo1_wave = 'Sine' +gen_drum_voice3_lfo1_start_phase = '0' +gen_drum_voice3_lfo1_smoothing = '0' +gen_drum_voice3_lfo1_trig_mode = 'Free' +gen_drum_voice3_lfo1_destination = '0' +gen_drum_voice3_lfo1_depth = '0.5' +gen_drum_voice3_lfo2_speed = '0.75' +gen_drum_voice3_lfo2_multiplier = '3' +gen_drum_voice3_lfo2_fade = '0.5' +gen_drum_voice3_lfo2_wave = 'Triangle' +gen_drum_voice3_lfo2_start_phase = '0' +gen_drum_voice3_lfo2_smoothing = '0' +gen_drum_voice3_lfo2_trig_mode = 'Free' +gen_drum_voice3_lfo2_destination = '0' +gen_drum_voice3_lfo2_depth = '0.5' +gen_drum_voice3_mod_env_delay = '0' +gen_drum_voice3_mod_env_attack = '0' +gen_drum_voice3_mod_env_decay = '0.503937' +gen_drum_voice3_mod_env_sustain = '0' +gen_drum_voice3_mod_env_release = '0.503937' +gen_drum_voice3_mod_env_reset = '0' +gen_drum_voice3_mod_env_destination = '0' +gen_drum_voice3_mod_env_depth = '0.5' +gen_drum_voice4_tune = '0.50275' +gen_drum_voice4_play_mode = '1' +gen_drum_voice4_sample_start = '0.17074999' +gen_drum_voice4_play_length = '1' +gen_drum_voice4_loop_point = '0.66791666' +gen_drum_voice4_overdrive = '0.7007874' +gen_drum_voice4_base = '0.43307087' +gen_drum_voice4_width = '1' +gen_drum_voice4_filter_type = '0' +gen_drum_voice4_filter_frequency = '1' +gen_drum_voice4_filter_resonance = '0' +gen_drum_voice4_filter_env_delay = '0' +gen_drum_voice4_filter_env_attack = '0' +gen_drum_voice4_filter_env_decay = '0.503937' +gen_drum_voice4_filter_env_sustain = '0' +gen_drum_voice4_filter_env_release = '0.503937' +gen_drum_voice4_filter_env_depth = '0.5' +gen_drum_voice4_filter_env_reset = '0' +gen_drum_voice4_stereo_spread = '0.4921875' +gen_drum_voice4_amp_mode = '1' +gen_drum_voice4_amp_env_attack = '0' +gen_drum_voice4_amp_env_hold = '1' +gen_drum_voice4_amp_env_decay = '0.68503934' +gen_drum_voice4_amp_env_sustain = '1' +gen_drum_voice4_amp_env_release = '0.2519685' +gen_drum_voice4_amp_env_reset = '1' +gen_drum_voice4_volume = '0.77952754' +gen_drum_voice4_pan = '0.5' +gen_drum_voice4_lfo1_speed = '0.75' +gen_drum_voice4_lfo1_multiplier = '1' +gen_drum_voice4_lfo1_fade = '0.5' +gen_drum_voice4_lfo1_wave = 'Triangle' +gen_drum_voice4_lfo1_start_phase = '0' +gen_drum_voice4_lfo1_smoothing = '0' +gen_drum_voice4_lfo1_trig_mode = 'Free' +gen_drum_voice4_lfo1_destination = '19' +gen_drum_voice4_lfo1_depth = '0.7293359' +gen_drum_voice4_lfo2_speed = '0.6735156' +gen_drum_voice4_lfo2_multiplier = '3' +gen_drum_voice4_lfo2_fade = '0.5' +gen_drum_voice4_lfo2_wave = 'Sine' +gen_drum_voice4_lfo2_start_phase = '0' +gen_drum_voice4_lfo2_smoothing = '0' +gen_drum_voice4_lfo2_trig_mode = 'Free' +gen_drum_voice4_lfo2_destination = '3' +gen_drum_voice4_lfo2_depth = '0.71304685' +gen_drum_voice4_mod_env_delay = '0' +gen_drum_voice4_mod_env_attack = '0' +gen_drum_voice4_mod_env_decay = '0.503937' +gen_drum_voice4_mod_env_sustain = '0' +gen_drum_voice4_mod_env_release = '0.503937' +gen_drum_voice4_mod_env_reset = '0' +gen_drum_voice4_mod_env_destination = '0' +gen_drum_voice4_mod_env_depth = '0.5' +gen_drum_voice5_tune = '0.5' +gen_drum_voice5_play_mode = '1' +gen_drum_voice5_sample_start = '0' +gen_drum_voice5_play_length = '1' +gen_drum_voice5_loop_point = '-0.00008333333' +gen_drum_voice5_overdrive = '0' +gen_drum_voice5_base = '0' +gen_drum_voice5_width = '1' +gen_drum_voice5_filter_type = '0' +gen_drum_voice5_filter_frequency = '1' +gen_drum_voice5_filter_resonance = '0' +gen_drum_voice5_filter_env_delay = '0' +gen_drum_voice5_filter_env_attack = '0' +gen_drum_voice5_filter_env_decay = '0.503937' +gen_drum_voice5_filter_env_sustain = '0' +gen_drum_voice5_filter_env_release = '0.503937' +gen_drum_voice5_filter_env_depth = '0.5' +gen_drum_voice5_filter_env_reset = '0' +gen_drum_voice5_stereo_spread = '0.5' +gen_drum_voice5_amp_mode = '1' +gen_drum_voice5_amp_env_attack = '0' +gen_drum_voice5_amp_env_hold = '0' +gen_drum_voice5_amp_env_decay = '1' +gen_drum_voice5_amp_env_sustain = '1' +gen_drum_voice5_amp_env_release = '0.2519685' +gen_drum_voice5_amp_env_reset = '1' +gen_drum_voice5_volume = '0.78740156' +gen_drum_voice5_pan = '0.5' +gen_drum_voice5_lfo1_speed = '0.75' +gen_drum_voice5_lfo1_multiplier = '1' +gen_drum_voice5_lfo1_fade = '0.5' +gen_drum_voice5_lfo1_wave = 'Sine' +gen_drum_voice5_lfo1_start_phase = '0' +gen_drum_voice5_lfo1_smoothing = '0' +gen_drum_voice5_lfo1_trig_mode = 'Free' +gen_drum_voice5_lfo1_destination = '0' +gen_drum_voice5_lfo1_depth = '0.5' +gen_drum_voice5_lfo2_speed = '0.75' +gen_drum_voice5_lfo2_multiplier = '3' +gen_drum_voice5_lfo2_fade = '0.5' +gen_drum_voice5_lfo2_wave = 'Triangle' +gen_drum_voice5_lfo2_start_phase = '0' +gen_drum_voice5_lfo2_smoothing = '0' +gen_drum_voice5_lfo2_trig_mode = 'Free' +gen_drum_voice5_lfo2_destination = '0' +gen_drum_voice5_lfo2_depth = '0.5' +gen_drum_voice5_mod_env_delay = '0' +gen_drum_voice5_mod_env_attack = '0' +gen_drum_voice5_mod_env_decay = '0.503937' +gen_drum_voice5_mod_env_sustain = '0' +gen_drum_voice5_mod_env_release = '0.503937' +gen_drum_voice5_mod_env_reset = '0' +gen_drum_voice5_mod_env_destination = '0' +gen_drum_voice5_mod_env_depth = '0.5' +gen_drum_voice6_tune = '0.5' +gen_drum_voice6_play_mode = '1' +gen_drum_voice6_sample_start = '0' +gen_drum_voice6_play_length = '1' +gen_drum_voice6_loop_point = '-0.00008333333' +gen_drum_voice6_overdrive = '0.08661418' +gen_drum_voice6_base = '0' +gen_drum_voice6_width = '1' +gen_drum_voice6_filter_type = '0' +gen_drum_voice6_filter_frequency = '1' +gen_drum_voice6_filter_resonance = '0' +gen_drum_voice6_filter_env_delay = '0' +gen_drum_voice6_filter_env_attack = '0' +gen_drum_voice6_filter_env_decay = '0.503937' +gen_drum_voice6_filter_env_sustain = '0' +gen_drum_voice6_filter_env_release = '0.503937' +gen_drum_voice6_filter_env_depth = '0.5' +gen_drum_voice6_filter_env_reset = '0' +gen_drum_voice6_stereo_spread = '0.5' +gen_drum_voice6_amp_mode = '1' +gen_drum_voice6_amp_env_attack = '0' +gen_drum_voice6_amp_env_hold = '0.062992126' +gen_drum_voice6_amp_env_decay = '0.18897638' +gen_drum_voice6_amp_env_sustain = '1' +gen_drum_voice6_amp_env_release = '0.2519685' +gen_drum_voice6_amp_env_reset = '1' +gen_drum_voice6_volume = '0.7559055' +gen_drum_voice6_pan = '0.5' +gen_drum_voice6_lfo1_speed = '0.625' +gen_drum_voice6_lfo1_multiplier = '2' +gen_drum_voice6_lfo1_fade = '0.5' +gen_drum_voice6_lfo1_wave = 'Triangle' +gen_drum_voice6_lfo1_start_phase = '0' +gen_drum_voice6_lfo1_smoothing = '0' +gen_drum_voice6_lfo1_trig_mode = 'Free' +gen_drum_voice6_lfo1_destination = '26' +gen_drum_voice6_lfo1_depth = '0.712539' +gen_drum_voice6_lfo2_speed = '0.75' +gen_drum_voice6_lfo2_multiplier = '3' +gen_drum_voice6_lfo2_fade = '0.5' +gen_drum_voice6_lfo2_wave = 'Triangle' +gen_drum_voice6_lfo2_start_phase = '0' +gen_drum_voice6_lfo2_smoothing = '0' +gen_drum_voice6_lfo2_trig_mode = 'Free' +gen_drum_voice6_lfo2_destination = '23' +gen_drum_voice6_lfo2_depth = '0.54359376' +gen_drum_voice6_mod_env_delay = '0' +gen_drum_voice6_mod_env_attack = '0' +gen_drum_voice6_mod_env_decay = '0.503937' +gen_drum_voice6_mod_env_sustain = '0' +gen_drum_voice6_mod_env_release = '0.503937' +gen_drum_voice6_mod_env_reset = '0' +gen_drum_voice6_mod_env_destination = '0' +gen_drum_voice6_mod_env_depth = '0.5' +gen_drum_voice7_tune = '0.5' +gen_drum_voice7_play_mode = '1' +gen_drum_voice7_sample_start = '0' +gen_drum_voice7_play_length = '1' +gen_drum_voice7_loop_point = '-0.00008333333' +gen_drum_voice7_overdrive = '0.14173228' +gen_drum_voice7_base = '0.6692913' +gen_drum_voice7_width = '1' +gen_drum_voice7_filter_type = '0' +gen_drum_voice7_filter_frequency = '1' +gen_drum_voice7_filter_resonance = '0' +gen_drum_voice7_filter_env_delay = '0' +gen_drum_voice7_filter_env_attack = '0' +gen_drum_voice7_filter_env_decay = '0.503937' +gen_drum_voice7_filter_env_sustain = '0' +gen_drum_voice7_filter_env_release = '0.503937' +gen_drum_voice7_filter_env_depth = '0.5' +gen_drum_voice7_filter_env_reset = '0' +gen_drum_voice7_stereo_spread = '0.5' +gen_drum_voice7_amp_mode = '1' +gen_drum_voice7_amp_env_attack = '0' +gen_drum_voice7_amp_env_hold = '0' +gen_drum_voice7_amp_env_decay = '1' +gen_drum_voice7_amp_env_sustain = '1' +gen_drum_voice7_amp_env_release = '0.2519685' +gen_drum_voice7_amp_env_reset = '1' +gen_drum_voice7_volume = '0.7480315' +gen_drum_voice7_pan = '0.359375' +gen_drum_voice7_lfo1_speed = '0.75' +gen_drum_voice7_lfo1_multiplier = '1' +gen_drum_voice7_lfo1_fade = '0.5' +gen_drum_voice7_lfo1_wave = 'Sine' +gen_drum_voice7_lfo1_start_phase = '0' +gen_drum_voice7_lfo1_smoothing = '0' +gen_drum_voice7_lfo1_trig_mode = 'Free' +gen_drum_voice7_lfo1_destination = '0' +gen_drum_voice7_lfo1_depth = '0.5' +gen_drum_voice7_lfo2_speed = '0.75' +gen_drum_voice7_lfo2_multiplier = '3' +gen_drum_voice7_lfo2_fade = '0.5' +gen_drum_voice7_lfo2_wave = 'Triangle' +gen_drum_voice7_lfo2_start_phase = '0' +gen_drum_voice7_lfo2_smoothing = '0' +gen_drum_voice7_lfo2_trig_mode = 'Free' +gen_drum_voice7_lfo2_destination = '0' +gen_drum_voice7_lfo2_depth = '0.5' +gen_drum_voice7_mod_env_delay = '0' +gen_drum_voice7_mod_env_attack = '0' +gen_drum_voice7_mod_env_decay = '0.503937' +gen_drum_voice7_mod_env_sustain = '0' +gen_drum_voice7_mod_env_release = '0.503937' +gen_drum_voice7_mod_env_reset = '0' +gen_drum_voice7_mod_env_destination = '0' +gen_drum_voice7_mod_env_depth = '0.5' + diff --git a/src/main/resources/de/mossgrabers/convertwithmoss/templates/tonverk/multi-template.tvpst b/src/main/resources/de/mossgrabers/convertwithmoss/templates/tonverk/multi-template.tvpst new file mode 100644 index 00000000..cea484f0 --- /dev/null +++ b/src/main/resources/de/mossgrabers/convertwithmoss/templates/tonverk/multi-template.tvpst @@ -0,0 +1,146 @@ +version = 2 +category = 'KEYS' +tags = [ + 'NOISY', + 'PAD', + 'VINTAGE', +] + +[parameters] +arp_enabled = '0' +arp_speed = '13' +arp_note_length = '14' +arp_mode = '0' +arp_range = '1' +lfo1_speed = '32' +lfo1_multiplier = '1' +lfo1_destination = '' +lfo1_depth = '0' +lfo1_waveform = 'Sine' +lfo1_start_phase = '0' +lfo1_trig_mode = 'Free' +lfo1_smoothing = '0' +lfo2_speed = '32' +lfo2_multiplier = '3' +lfo2_destination = '' +lfo2_depth = '0' +lfo2_waveform = 'Triangle' +lfo2_start_phase = '0' +lfo2_trig_mode = 'Free' +lfo2_fade = '0' +lfo2_smoothing = '0' +pitchbend_value = '0' +pitchbend_mod1_destination = '' +pitchbend_mod2_destination = '' +pitchbend_mod3_destination = '' +pitchbend_mod4_destination = '' +pitchbend_mod1_depth = '0' +pitchbend_mod2_depth = '0' +pitchbend_mod3_depth = '0' +pitchbend_mod4_depth = '0' +aftertouch_value = '0' +aftertouch_mod1_destination = '' +aftertouch_mod2_destination = '' +aftertouch_mod3_destination = '' +aftertouch_mod4_destination = '' +aftertouch_mod1_depth = '0' +aftertouch_mod2_depth = '0' +aftertouch_mod3_depth = '0' +aftertouch_mod4_depth = '0' +modwheel_value = '0' +modwheel_mod1_destination = '' +modwheel_mod2_destination = '' +modwheel_mod3_destination = '' +modwheel_mod4_destination = '' +modwheel_mod1_depth = '0' +modwheel_mod2_depth = '0' +modwheel_mod3_depth = '0' +modwheel_mod4_depth = '0' +breath_control_value = '0' +breath_control_mod1_destination = '' +breath_control_mod2_destination = '' +breath_control_mod3_destination = '' +breath_control_mod4_destination = '' +breath_control_mod1_depth = '0' +breath_control_mod2_depth = '0' +breath_control_mod3_depth = '0' +breath_control_mod4_depth = '0' +fx1_machine = '0' +fx1_dirtshaper_drive = '0.26771653' +fx1_dirtshaper_rectify = '0.17322835' +fx1_dirtshaper_hpf = '0.70866144' +fx1_dirtshaper_lpf = '0.9448819' +fx1_dirtshaper_xnoise = '0.047244094' +fx1_dirtshaper_noise_freq = '0.11811024' +fx1_dirtshaper_noise_reso = '0.03937008' +fx1_dirtshaper_mix = '0.29999998' +fx2_machine = '0' +fx2_saturator_delay_time = '0.08629921' +fx2_saturator_delay_mode = '1' +fx2_saturator_delay_width = '0.8203125' +fx2_saturator_delay_feedback = '0.3181818' +fx2_saturator_delay_hp = '0.21259843' +fx2_saturator_delay_lp = '0.62992126' +fx2_saturator_delay_mix = '0.19' +midi_machine = '0' +gen_machine = '1' +gen_multi_poly_mode = '0' +gen_multi_note_priority = '0' +gen_multi_reuse_voices = '0' +gen_multi_octave = '0' +gen_multi_velocity_curve = '3' +gen_multi_tune = '0.5000833' +gen_multi_vibrato_depth = '0.03937008' +gen_multi_vibrato_speed = '0.19685039' +gen_multi_vibrato_fade = '0.5' +gen_multi_overdrive = '0.23622048' +gen_multi_base = '0' +gen_multi_width = '1' +gen_multi_filter_type = '0' +gen_multi_filter_frequency = '0.20023622' +gen_multi_filter_resonance = '0.031496063' +gen_multi_filter_env_delay = '0' +gen_multi_filter_env_attack = '0.41732284' +gen_multi_filter_env_decay = '0.6535433' +gen_multi_filter_env_sustain = '0.70866144' +gen_multi_filter_env_release = '0.6535433' +gen_multi_filter_env_depth = '0.734375' +gen_multi_filter_env_reset = '0' +gen_multi_filter_stereo_spread = '0.5' +gen_multi_filter_key_tracking = '0.51' +gen_multi_amp_mode = '2' +gen_multi_amp_env_attack = '0.4015748' +gen_multi_amp_env_hold = '0' +gen_multi_amp_env_decay = '0.503937' +gen_multi_amp_env_sustain = '1' +gen_multi_amp_env_release = '0.5826772' +gen_multi_amp_env_reset = '1' +gen_multi_volume = '0.6692913' +gen_multi_pan = '0.5' +gen_multi_lfo1_speed = '0.75' +gen_multi_lfo1_multiplier = '1' +gen_multi_lfo1_fade = '0.5' +gen_multi_lfo1_wave = 'Sine' +gen_multi_lfo1_start_phase = '0' +gen_multi_lfo1_smoothing = '0' +gen_multi_lfo1_trig_mode = 'Free' +gen_multi_lfo1_destination = '0' +gen_multi_lfo1_depth = '0.5' +gen_multi_lfo2_speed = '0.7710937' +gen_multi_lfo2_multiplier = '2' +gen_multi_lfo2_fade = '0.5' +gen_multi_lfo2_wave = 'Sine' +gen_multi_lfo2_start_phase = '0' +gen_multi_lfo2_smoothing = '0' +gen_multi_lfo2_trig_mode = 'Free' +gen_multi_lfo2_destination = '8' +gen_multi_lfo2_depth = '0.5184375' +gen_multi_mod_env_delay = '0' +gen_multi_mod_env_attack = '0' +gen_multi_mod_env_decay = '0.503937' +gen_multi_mod_env_sustain = '0' +gen_multi_mod_env_release = '0.503937' +gen_multi_mod_env_reset = '0' +gen_multi_mod_env_destination = '0' +gen_multi_mod_env_depth = '0.5' + From 8859a5c6ea72474dca47a0e1ba9dd9b4434242fb Mon Sep 17 00:00:00 2001 From: Douglas Carmichael Date: Sat, 27 Jun 2026 09:14:42 -0400 Subject: [PATCH 02/13] Name the Elektron Tonverk multi-sample mapping consistently The .elmulti/.eldrum multi-sample mapping format was labelled "Elektron Multi" when reading but "Elektron Tonverk" when writing, and the latter was easily confused with the new "Elektron Tonverk Preset". It is now "Elektron Tonverk Multi-Sample" on both sides. The CLI prefixes are left unchanged for backward compatibility. --- documentation/CHANGELOG.md | 2 +- .../convertwithmoss/format/elektron/TonverkMultiCreator.java | 2 +- .../convertwithmoss/format/elektron/TonverkMultiDetector.java | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/documentation/CHANGELOG.md b/documentation/CHANGELOG.md index 57f6d0bd..f6d908a4 100644 --- a/documentation/CHANGELOG.md +++ b/documentation/CHANGELOG.md @@ -4,7 +4,7 @@ * New: Improved user interface for long lists of formats. * New: Added support for the Elektron Tonverk preset (TVPST) format - read (One-Shot, Multi and Drum machines, including amplitude and filter envelopes) and write (Multi or Drum machine) (thanks to Douglas Carmichael). -* New: Added support for the Polyend Tracker (PTI) instrument format (thanks to Douglas Carmichael). +* New: The Elektron Tonverk multi-sample mapping format (.elmulti/.eldrum) is now labelled "Elektron Tonverk Multisample" consistently for both reading and writing - it was previously shown as "Elektron Multi" as a source but "Elektron Tonverk" as a destination, the latter being easily confused with "Elektron Tonverk Preset" (thanks to Douglas Carmichael). * New: Added support for the Renoise instrument (XRNI) format (thanks to Douglas Carmichael). * New: Added support for the Synthstrom Deluge instrument format (thanks to Douglas Carmichael). * New: Added support for the Downloadable Sound format (DLS) - read only. diff --git a/src/main/java/de/mossgrabers/convertwithmoss/format/elektron/TonverkMultiCreator.java b/src/main/java/de/mossgrabers/convertwithmoss/format/elektron/TonverkMultiCreator.java index 01725179..1e134e3b 100644 --- a/src/main/java/de/mossgrabers/convertwithmoss/format/elektron/TonverkMultiCreator.java +++ b/src/main/java/de/mossgrabers/convertwithmoss/format/elektron/TonverkMultiCreator.java @@ -76,7 +76,7 @@ public class TonverkMultiCreator extends AbstractWavCreator */ public TonverkMultiDetector (final INotifier notifier) { - super ("Elektron Multi", "Elektron", notifier, new MetadataSettingsUI ("Elektron"), ".elmulti", ".eldrum"); + super ("Elektron Tonverk Multisample", "Elektron", notifier, new MetadataSettingsUI ("Elektron"), ".elmulti", ".eldrum"); } From 39627f994227fcc901127c6ac4dbca77973defde Mon Sep 17 00:00:00 2001 From: Douglas Carmichael Date: Sat, 27 Jun 2026 11:51:20 -0400 Subject: [PATCH 03/13] Fix single-quote handling in Tonverk preset (TVPST) string values A preset whose name (or category/tag) contained a single quote was written into the single-quoted TOML scalar verbatim, e.g. name = 'Tonverk's Test', which is malformed and rejected by the Tonverk. Mirror the elmulti writer: fall back to a double-quoted string when the value contains a single quote, and accept double-quoted values when reading back. --- .../format/elektron/TonverkPresetFile.java | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/main/java/de/mossgrabers/convertwithmoss/format/elektron/TonverkPresetFile.java b/src/main/java/de/mossgrabers/convertwithmoss/format/elektron/TonverkPresetFile.java index aff25e15..ecd0c12a 100644 --- a/src/main/java/de/mossgrabers/convertwithmoss/format/elektron/TonverkPresetFile.java +++ b/src/main/java/de/mossgrabers/convertwithmoss/format/elektron/TonverkPresetFile.java @@ -484,15 +484,26 @@ private static String stripSectionBrackets (final String line) private static String stripQuotes (final String value) { final String trimmed = value.trim (); - if (trimmed.length () >= 2 && trimmed.startsWith ("'") && trimmed.endsWith ("'")) + if (trimmed.length () >= 2 && (trimmed.startsWith ("\"") && trimmed.endsWith ("\"") || trimmed.startsWith ("'") && trimmed.endsWith ("'"))) return trimmed.substring (1, trimmed.length () - 1); return trimmed; } + /** + * Quotes a string value. Uses single quotes except when the value contains a single quote, in + * that case double quotes are used and contained double quotes are escaped (single-quoted TOML + * scalars cannot contain a single quote, which the Tonverk rejects). + * + * @param value The value to quote + * @return The quoted value + */ private static String quote (final String value) { - return "'" + (value == null ? "" : value) + "'"; + final String text = value == null ? "" : value; + if (!text.contains ("'")) + return "'" + text + "'"; + return '"' + text.replace ("\"", "\\\"") + '"'; } From cbb28a1ba4b9a699a60ac706623c81514985801a Mon Sep 17 00:00:00 2001 From: Douglas Carmichael Date: Sat, 27 Jun 2026 12:56:55 -0400 Subject: [PATCH 04/13] Write Tonverk presets in the device's SD-card layout A *.tvpst preset is shown on the Tonverk only when it is a flat file in 'User/Presets'; its samples must live separately under 'User/Multi-sampled Instruments/' and be referenced by their absolute device path. The creator previously wrote a folder per preset with the samples next to it and referenced them by bare file name (the elmulti convention), so the device listed nothing. The creator now mirrors the SD-card layout below the chosen output folder: 'User/Presets/.tvpst' plus 'User/Multi-sampled Instruments//.wav', each referenced as '/mnt/sdcard/User/Multi-sampled Instruments//.wav'. Both sub-trees accumulate across presets when a whole library is converted, and the created 'User' folder can be copied straight onto the device. The detector already strips the mount prefix and resolves samples against the preset folder's ancestors, so reading round-trips. --- documentation/CHANGELOG.md | 2 +- .../format/elektron/TonverkPresetCreator.java | 84 +++++++++++++++---- 2 files changed, 71 insertions(+), 15 deletions(-) diff --git a/documentation/CHANGELOG.md b/documentation/CHANGELOG.md index f6d908a4..b1332458 100644 --- a/documentation/CHANGELOG.md +++ b/documentation/CHANGELOG.md @@ -3,7 +3,7 @@ ## 19.0.0 (unreleased) * New: Improved user interface for long lists of formats. -* New: Added support for the Elektron Tonverk preset (TVPST) format - read (One-Shot, Multi and Drum machines, including amplitude and filter envelopes) and write (Multi or Drum machine) (thanks to Douglas Carmichael). +* New: Added support for the Elektron Tonverk preset (TVPST) format - read (One-Shot, Multi and Drum machines, including amplitude and filter envelopes) and write (Multi or Drum machine). Writing mirrors the device's SD-card layout: the preset is stored as a flat file in 'User/Presets' and its samples in 'User/Multi-sampled Instruments/', referenced by their absolute device path, so the created 'User' folder can be copied straight onto the Tonverk (thanks to Douglas Carmichael). * New: The Elektron Tonverk multi-sample mapping format (.elmulti/.eldrum) is now labelled "Elektron Tonverk Multisample" consistently for both reading and writing - it was previously shown as "Elektron Multi" as a source but "Elektron Tonverk" as a destination, the latter being easily confused with "Elektron Tonverk Preset" (thanks to Douglas Carmichael). * New: Added support for the Renoise instrument (XRNI) format (thanks to Douglas Carmichael). * New: Added support for the Synthstrom Deluge instrument format (thanks to Douglas Carmichael). diff --git a/src/main/java/de/mossgrabers/convertwithmoss/format/elektron/TonverkPresetCreator.java b/src/main/java/de/mossgrabers/convertwithmoss/format/elektron/TonverkPresetCreator.java index 81ad4810..16420301 100644 --- a/src/main/java/de/mossgrabers/convertwithmoss/format/elektron/TonverkPresetCreator.java +++ b/src/main/java/de/mossgrabers/convertwithmoss/format/elektron/TonverkPresetCreator.java @@ -42,11 +42,17 @@ /** - * Creator for Elektron Tonverk preset files (*.tvpst). A preset bundles a description file and the - * related (relatively referenced) samples in one folder. The full [parameters] block is - * created from a neutral factory template (FX bypassed, LFOs/arpeggiator/modulation neutralized); the - * amplitude envelope, the filter and its envelope, gain and panning are written from the model. The - * output engine (Multi or Drum) can be selected; the Drum machine always has eight voices. + * Creator for Elektron Tonverk preset files (*.tvpst). The Tonverk lists presets only as flat + * *.tvpst files in User/Presets and resolves their samples by an absolute + * device path; therefore this creator mirrors the device's SD-card layout below the chosen output + * folder: the preset is written to User/Presets/<name>.tvpst and its samples to + * User/Multi-sampled Instruments/<name>/, each referenced as + * /mnt/sdcard/User/Multi-sampled Instruments/<name>/<sample>.wav. The whole + * User folder can then be copied straight onto the device. The full + * [parameters] block is created from a neutral factory template (FX bypassed, + * LFOs/arpeggiator/modulation neutralized); the amplitude envelope, the filter and its envelope, + * gain and panning are written from the model. The output engine (Multi or Drum) can be selected; + * the Drum machine always has eight voices. * * @author Jürgen Moßgraber */ @@ -62,6 +68,13 @@ public class TonverkPresetCreator extends AbstractWavCreator'. Both sub-trees accumulate across presets + // when a whole library is converted. + final File presetsFolder = new File (destinationFolder, PRESETS_SUBPATH); + if (!presetsFolder.isDirectory () && !presetsFolder.mkdirs ()) { - this.notifier.logError ("IDS_NOTIFY_FOLDER_COULD_NOT_BE_CREATED", presetFolder.getAbsolutePath ()); + this.notifier.logError ("IDS_NOTIFY_FOLDER_COULD_NOT_BE_CREATED", presetsFolder.getAbsolutePath ()); + return; + } + + final File presetFile = this.createUniqueFilename (presetsFolder, createSafeFilename (multisampleSource.getName ()), "tvpst"); + final String presetFileName = presetFile.getName (); + final String presetName = presetFileName.substring (0, presetFileName.length () - ".tvpst".length ()); + + final File sampleFolder = new File (new File (destinationFolder, INSTRUMENTS_SUBPATH), presetName); + if (!sampleFolder.isDirectory () && !sampleFolder.mkdirs ()) + { + this.notifier.logError ("IDS_NOTIFY_FOLDER_COULD_NOT_BE_CREATED", sampleFolder.getAbsolutePath ()); return; } - final String presetName = presetFolder.getName (); multisampleSource.setGroups (this.combineSplitStereo (multisampleSource)); @@ -111,22 +136,53 @@ public void createPreset (final File destinationFolder, final IMultisampleSource if (resample) TonverkMultiCreator.recalculateForResample (multisampleSource); - // The samples are physically trimmed to the zone start/stop and referenced relatively - this.writeSamples (presetFolder, multisampleSource, resample ? OPTIMIZED_AUDIO_FORMAT : DEFAULT_AUDIO_FORMAT, true); + // The samples are physically trimmed to the zone start/stop and stored in the instrument + // folder; they are referenced from the preset by their absolute device path (see below) + this.writeSamples (sampleFolder, multisampleSource, resample ? OPTIMIZED_AUDIO_FORMAT : DEFAULT_AUDIO_FORMAT, true); // The preset must be created after the samples were written since trimming updates the // zone/loop positions final TonverkPresetFile preset = drum ? this.buildDrumPreset (multisampleSource, presetName) : this.buildMultiPreset (multisampleSource); applyMetadata (preset, multisampleSource); - final String presetFileName = presetName + ".tvpst"; + // The Tonverk only resolves a preset's samples through an absolute '/mnt/sdcard/...' path; + // a bare file name (as used by the elmulti format) is not found + referenceSamplesAbsolutely (preset, presetName); + this.notifier.log ("IDS_NOTIFY_STORING", presetFileName); - preset.write (new File (presetFolder, presetFileName).toPath ()); + preset.write (presetFile.toPath ()); this.progress.notifyDone (); } + /** + * Rewrites every sample reference of the preset to its absolute device path under + * User/Multi-sampled Instruments/<preset>. The mapping builders store a bare + * file name (correct for the elmulti format, whose description file sits next to its samples); + * a *.tvpst preset, however, is a flat file in User/Presets and the Tonverk only + * finds its samples through such an absolute /mnt/sdcard/... path. + * + * @param preset The preset whose sample slots are updated in place + * @param presetName The preset name which is also the instrument sub-folder name + */ + private static void referenceSamplesAbsolutely (final TonverkPresetFile preset, final String presetName) + { + final String deviceFolder = DEVICE_SAMPLE_FOLDER + presetName + "/"; + for (final TonverkKeyZone keyZone: preset.keyZones) + for (final TonverkVelocityLayer velocityLayer: keyZone.velocityLayers) + for (final TonverkSampleSlot sampleSlot: velocityLayer.sampleSlots) + { + final String sample = sampleSlot.sample; + if (sample == null || sample.isBlank ()) + continue; + // Keep only the file name in case a path was already set + final int slash = Math.max (sample.lastIndexOf ('/'), sample.lastIndexOf ('\\')); + sampleSlot.sample = deviceFolder + (slash >= 0 ? sample.substring (slash + 1) : sample); + } + } + + private boolean isDrumOutput (final IMultisampleSource multisampleSource) { return switch (this.settingsConfiguration.getOutputEngine ()) From 2d39a52102cb470fcf46ac6d554377aba083bdf5 Mon Sep 17 00:00:00 2001 From: Douglas Carmichael Date: Sat, 27 Jun 2026 22:39:40 -0400 Subject: [PATCH 05/13] Neutralize per-machine LFOs in the Tonverk preset templates The neutral *.tvpst templates were captured from factory presets, but the per-machine LFOs were not fully reset: the Multi template's LFO2 had a real destination (8) and an off-center depth (0.518), and Drum voices 1, 4 and 6 likewise had routed LFOs with off-center depths. Per the manual a depth of 0.5 is the center that equals no modulation, so these non-0.5 depths made every converted preset modulate audibly - a ~1 s warble on a held note. All per-machine LFOs are now set to destination 0 (off) and depth 0.5 (no modulation), matching a static factory preset. --- .../templates/tonverk/drum-template.tvpst | 18 +++++++++--------- .../templates/tonverk/multi-template.tvpst | 4 ++-- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/main/resources/de/mossgrabers/convertwithmoss/templates/tonverk/drum-template.tvpst b/src/main/resources/de/mossgrabers/convertwithmoss/templates/tonverk/drum-template.tvpst index b3cd601f..2d3e5e2a 100644 --- a/src/main/resources/de/mossgrabers/convertwithmoss/templates/tonverk/drum-template.tvpst +++ b/src/main/resources/de/mossgrabers/convertwithmoss/templates/tonverk/drum-template.tvpst @@ -177,7 +177,7 @@ gen_drum_voice1_lfo1_start_phase = '0' gen_drum_voice1_lfo1_smoothing = '0' gen_drum_voice1_lfo1_trig_mode = 'Hold' gen_drum_voice1_lfo1_destination = '0' -gen_drum_voice1_lfo1_depth = '0.50003904' +gen_drum_voice1_lfo1_depth = '0.5' gen_drum_voice1_lfo2_speed = '0.75' gen_drum_voice1_lfo2_multiplier = '3' gen_drum_voice1_lfo2_fade = '0.5' @@ -338,8 +338,8 @@ gen_drum_voice4_lfo1_wave = 'Triangle' gen_drum_voice4_lfo1_start_phase = '0' gen_drum_voice4_lfo1_smoothing = '0' gen_drum_voice4_lfo1_trig_mode = 'Free' -gen_drum_voice4_lfo1_destination = '19' -gen_drum_voice4_lfo1_depth = '0.7293359' +gen_drum_voice4_lfo1_destination = '0' +gen_drum_voice4_lfo1_depth = '0.5' gen_drum_voice4_lfo2_speed = '0.6735156' gen_drum_voice4_lfo2_multiplier = '3' gen_drum_voice4_lfo2_fade = '0.5' @@ -347,8 +347,8 @@ gen_drum_voice4_lfo2_wave = 'Sine' gen_drum_voice4_lfo2_start_phase = '0' gen_drum_voice4_lfo2_smoothing = '0' gen_drum_voice4_lfo2_trig_mode = 'Free' -gen_drum_voice4_lfo2_destination = '3' -gen_drum_voice4_lfo2_depth = '0.71304685' +gen_drum_voice4_lfo2_destination = '0' +gen_drum_voice4_lfo2_depth = '0.5' gen_drum_voice4_mod_env_delay = '0' gen_drum_voice4_mod_env_attack = '0' gen_drum_voice4_mod_env_decay = '0.503937' @@ -446,8 +446,8 @@ gen_drum_voice6_lfo1_wave = 'Triangle' gen_drum_voice6_lfo1_start_phase = '0' gen_drum_voice6_lfo1_smoothing = '0' gen_drum_voice6_lfo1_trig_mode = 'Free' -gen_drum_voice6_lfo1_destination = '26' -gen_drum_voice6_lfo1_depth = '0.712539' +gen_drum_voice6_lfo1_destination = '0' +gen_drum_voice6_lfo1_depth = '0.5' gen_drum_voice6_lfo2_speed = '0.75' gen_drum_voice6_lfo2_multiplier = '3' gen_drum_voice6_lfo2_fade = '0.5' @@ -455,8 +455,8 @@ gen_drum_voice6_lfo2_wave = 'Triangle' gen_drum_voice6_lfo2_start_phase = '0' gen_drum_voice6_lfo2_smoothing = '0' gen_drum_voice6_lfo2_trig_mode = 'Free' -gen_drum_voice6_lfo2_destination = '23' -gen_drum_voice6_lfo2_depth = '0.54359376' +gen_drum_voice6_lfo2_destination = '0' +gen_drum_voice6_lfo2_depth = '0.5' gen_drum_voice6_mod_env_delay = '0' gen_drum_voice6_mod_env_attack = '0' gen_drum_voice6_mod_env_decay = '0.503937' diff --git a/src/main/resources/de/mossgrabers/convertwithmoss/templates/tonverk/multi-template.tvpst b/src/main/resources/de/mossgrabers/convertwithmoss/templates/tonverk/multi-template.tvpst index cea484f0..91686ed5 100644 --- a/src/main/resources/de/mossgrabers/convertwithmoss/templates/tonverk/multi-template.tvpst +++ b/src/main/resources/de/mossgrabers/convertwithmoss/templates/tonverk/multi-template.tvpst @@ -133,8 +133,8 @@ gen_multi_lfo2_wave = 'Sine' gen_multi_lfo2_start_phase = '0' gen_multi_lfo2_smoothing = '0' gen_multi_lfo2_trig_mode = 'Free' -gen_multi_lfo2_destination = '8' -gen_multi_lfo2_depth = '0.5184375' +gen_multi_lfo2_destination = '0' +gen_multi_lfo2_depth = '0.5' gen_multi_mod_env_delay = '0' gen_multi_mod_env_attack = '0' gen_multi_mod_env_decay = '0.503937' From e856357b80e1590cbc19dbfe7753c6b0138cdd9a Mon Sep 17 00:00:00 2001 From: Douglas Carmichael Date: Sat, 27 Jun 2026 23:32:11 -0400 Subject: [PATCH 06/13] Neutralize baked-in FX leftovers in Tonverk templates The multi/drum templates were captured from factory presets and carried non-neutral modulation/effect values that the writer never overwrites, so every converted preset inherited them and sounded colored: - multi: vibrato depth, overdrive, a non-open filter cutoff with resonance, and a filter-envelope sweep (depth off-center) - drum: overdrive on six voices and two non-open filter cutoffs Reset all to their neutral values (depth 0.5 = no modulation, destination/ overdrive/vibrato 0 = off, cutoff 1 = wide open) so a converted preset plays the source sample cleanly with only the amp envelope and any source filter. --- .../templates/tonverk/drum-template.tvpst | 16 ++++++++-------- .../templates/tonverk/multi-template.tvpst | 10 +++++----- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/main/resources/de/mossgrabers/convertwithmoss/templates/tonverk/drum-template.tvpst b/src/main/resources/de/mossgrabers/convertwithmoss/templates/tonverk/drum-template.tvpst index 2d3e5e2a..507dd79f 100644 --- a/src/main/resources/de/mossgrabers/convertwithmoss/templates/tonverk/drum-template.tvpst +++ b/src/main/resources/de/mossgrabers/convertwithmoss/templates/tonverk/drum-template.tvpst @@ -92,11 +92,11 @@ gen_drum_voice0_play_mode = '1' gen_drum_voice0_sample_start = '0' gen_drum_voice0_play_length = '1' gen_drum_voice0_loop_point = '-0.00008333333' -gen_drum_voice0_overdrive = '0.24409449' +gen_drum_voice0_overdrive = '0' gen_drum_voice0_base = '0' gen_drum_voice0_width = '1' gen_drum_voice0_filter_type = '0.9448819' -gen_drum_voice0_filter_frequency = '0.19669291' +gen_drum_voice0_filter_frequency = '1' gen_drum_voice0_filter_resonance = '0.53543305' gen_drum_voice0_filter_env_delay = '0' gen_drum_voice0_filter_env_attack = '0' @@ -146,11 +146,11 @@ gen_drum_voice1_play_mode = '1' gen_drum_voice1_sample_start = '0' gen_drum_voice1_play_length = '1' gen_drum_voice1_loop_point = '-0.00008333333' -gen_drum_voice1_overdrive = '0.08661418' +gen_drum_voice1_overdrive = '0' gen_drum_voice1_base = '0' gen_drum_voice1_width = '1' gen_drum_voice1_filter_type = '0' -gen_drum_voice1_filter_frequency = '0.91322833' +gen_drum_voice1_filter_frequency = '1' gen_drum_voice1_filter_resonance = '0' gen_drum_voice1_filter_env_delay = '0' gen_drum_voice1_filter_env_attack = '0' @@ -200,7 +200,7 @@ gen_drum_voice2_play_mode = '1' gen_drum_voice2_sample_start = '0.00066666666' gen_drum_voice2_play_length = '1' gen_drum_voice2_loop_point = '-0.00008333333' -gen_drum_voice2_overdrive = '0.87401575' +gen_drum_voice2_overdrive = '0' gen_drum_voice2_base = '0.46456692' gen_drum_voice2_width = '1' gen_drum_voice2_filter_type = '0' @@ -308,7 +308,7 @@ gen_drum_voice4_play_mode = '1' gen_drum_voice4_sample_start = '0.17074999' gen_drum_voice4_play_length = '1' gen_drum_voice4_loop_point = '0.66791666' -gen_drum_voice4_overdrive = '0.7007874' +gen_drum_voice4_overdrive = '0' gen_drum_voice4_base = '0.43307087' gen_drum_voice4_width = '1' gen_drum_voice4_filter_type = '0' @@ -416,7 +416,7 @@ gen_drum_voice6_play_mode = '1' gen_drum_voice6_sample_start = '0' gen_drum_voice6_play_length = '1' gen_drum_voice6_loop_point = '-0.00008333333' -gen_drum_voice6_overdrive = '0.08661418' +gen_drum_voice6_overdrive = '0' gen_drum_voice6_base = '0' gen_drum_voice6_width = '1' gen_drum_voice6_filter_type = '0' @@ -470,7 +470,7 @@ gen_drum_voice7_play_mode = '1' gen_drum_voice7_sample_start = '0' gen_drum_voice7_play_length = '1' gen_drum_voice7_loop_point = '-0.00008333333' -gen_drum_voice7_overdrive = '0.14173228' +gen_drum_voice7_overdrive = '0' gen_drum_voice7_base = '0.6692913' gen_drum_voice7_width = '1' gen_drum_voice7_filter_type = '0' diff --git a/src/main/resources/de/mossgrabers/convertwithmoss/templates/tonverk/multi-template.tvpst b/src/main/resources/de/mossgrabers/convertwithmoss/templates/tonverk/multi-template.tvpst index 91686ed5..3792cd52 100644 --- a/src/main/resources/de/mossgrabers/convertwithmoss/templates/tonverk/multi-template.tvpst +++ b/src/main/resources/de/mossgrabers/convertwithmoss/templates/tonverk/multi-template.tvpst @@ -90,21 +90,21 @@ gen_multi_reuse_voices = '0' gen_multi_octave = '0' gen_multi_velocity_curve = '3' gen_multi_tune = '0.5000833' -gen_multi_vibrato_depth = '0.03937008' +gen_multi_vibrato_depth = '0' gen_multi_vibrato_speed = '0.19685039' gen_multi_vibrato_fade = '0.5' -gen_multi_overdrive = '0.23622048' +gen_multi_overdrive = '0' gen_multi_base = '0' gen_multi_width = '1' gen_multi_filter_type = '0' -gen_multi_filter_frequency = '0.20023622' -gen_multi_filter_resonance = '0.031496063' +gen_multi_filter_frequency = '1' +gen_multi_filter_resonance = '0' gen_multi_filter_env_delay = '0' gen_multi_filter_env_attack = '0.41732284' gen_multi_filter_env_decay = '0.6535433' gen_multi_filter_env_sustain = '0.70866144' gen_multi_filter_env_release = '0.6535433' -gen_multi_filter_env_depth = '0.734375' +gen_multi_filter_env_depth = '0.5' gen_multi_filter_env_reset = '0' gen_multi_filter_stereo_spread = '0.5' gen_multi_filter_key_tracking = '0.51' From ac57f51a984518c8c1713aad92d029a4fe5c76ec Mon Sep 17 00:00:00 2001 From: Douglas Carmichael Date: Sat, 27 Jun 2026 23:50:52 -0400 Subject: [PATCH 07/13] Calibrate Tonverk envelope times against hardware The envelope MAX constants were educated guesses. A Tonverk resample of a preset with known normalized values shows the firmware uses the same seconds = max * normalized^3 cube law the writer uses, so the maxima could be measured directly: - attack: a linear ramp topping out near 1.6 s, not 8 s (two probes agree: norm 0.63 -> 0.40 s and norm 0.79 -> 0.78 s). The old 8 s made every converted attack about 5x too fast. - decay/release: an exponential fade whose time to -60 dB tops out near 22 s, not 24 s (norm 0.63 -> ~5.5 s) - a minor correction. Hold shares the attack scale and decay shares the release scale (both unprobed). Attack/release times within the device's range now round-trip to within a few percent; longer times clamp to the device's real ceiling. --- .../format/elektron/TonverkValues.java | 29 ++++++++++++------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/src/main/java/de/mossgrabers/convertwithmoss/format/elektron/TonverkValues.java b/src/main/java/de/mossgrabers/convertwithmoss/format/elektron/TonverkValues.java index d58a5c33..d2a58d9c 100644 --- a/src/main/java/de/mossgrabers/convertwithmoss/format/elektron/TonverkValues.java +++ b/src/main/java/de/mossgrabers/convertwithmoss/format/elektron/TonverkValues.java @@ -11,23 +11,32 @@ *

* The Tonverk firmware uses internal, non-published curves to map these normalized values to times * and frequencies. Since those curves are not contained in the preset files, the mappings below are - * documented approximations: a power curve for times and an exponential curve for the filter cut-off. - * They are chosen so that a Tonverk-to-Tonverk round-trip is loss-less (the inverse functions are - * exact), while a conversion to/from a unit-based format (e.g. seconds) is a close approximation. All - * range constants are gathered here so they can be tuned in one place. + * a power curve for times and an exponential curve for the filter cut-off. The envelope time maxima + * were calibrated against hardware resamples (see the constants below); the firmware was found to + * follow the same power curve, so the seconds mapping is close, not merely a guess. A + * Tonverk-to-Tonverk round-trip is loss-less either way (the inverse functions are exact). All range + * constants are gathered here so they can be tuned in one place. * * @author Jürgen Moßgraber */ public final class TonverkValues { + // Envelope time maxima (the seconds reached at normalized value 1.0). Hardware-calibrated + // 2026-06-27 against Tonverk resamples of presets with known normalized values: the firmware + // follows the same seconds = max * normalized^3 cube law the writer uses, so each maximum + // below is the device's measured time at normalized 1.0. Attack is a linear ramp topping out + // near 1.6 s (two probes agree: norm 0.63 -> 0.40 s and norm 0.79 -> 0.78 s, both implying + // max ~1.58 s). Decay/release are an exponential fade whose time to -60 dB tops out near 22 s + // (norm 0.63 -> ~5.5 s). Earlier guesses (attack 8 s, release 24 s) made attacks 5x too fast. + /** Maximum attack time in seconds (normalized value 1.0). */ - private static final double ATTACK_MAX_SECONDS = 8.0; - /** Maximum hold time in seconds (normalized value 1.0). */ - private static final double HOLD_MAX_SECONDS = 8.0; - /** Maximum decay time in seconds (normalized value 1.0). */ - private static final double DECAY_MAX_SECONDS = 24.0; + private static final double ATTACK_MAX_SECONDS = 1.6; + /** Maximum hold time in seconds (normalized value 1.0); shares the attack scale (unprobed). */ + private static final double HOLD_MAX_SECONDS = 1.6; + /** Maximum decay time in seconds (normalized value 1.0); shares the release scale (unprobed). */ + private static final double DECAY_MAX_SECONDS = 22.0; /** Maximum release time in seconds (normalized value 1.0). */ - private static final double RELEASE_MAX_SECONDS = 24.0; + private static final double RELEASE_MAX_SECONDS = 22.0; /** Maximum delay time in seconds (normalized value 1.0). */ private static final double DELAY_MAX_SECONDS = 4.0; /** From 434751fdd46c297e30d8832fa796e0b49905c0de Mon Sep 17 00:00:00 2001 From: Douglas Carmichael Date: Sun, 28 Jun 2026 00:36:38 -0400 Subject: [PATCH 08/13] Default Tonverk sample trim to the full sample A Multi or Drum mapping slot stores only loop points, no sample-trim start/end, so the detector left the zone's start/stop at the model default of -1. Creators that write the trim verbatim then emitted a bogus value - e.g. a Waldorf QPAT showed a sample start and end of -1 on the device instead of playing the whole sample. A slot without explicit trim points plays the entire sample, so default the start to 0 and the stop to the sample's frame count when no trim is stored. --- .../format/elektron/TonverkPresetDetector.java | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/main/java/de/mossgrabers/convertwithmoss/format/elektron/TonverkPresetDetector.java b/src/main/java/de/mossgrabers/convertwithmoss/format/elektron/TonverkPresetDetector.java index d2615a01..44cd7a64 100644 --- a/src/main/java/de/mossgrabers/convertwithmoss/format/elektron/TonverkPresetDetector.java +++ b/src/main/java/de/mossgrabers/convertwithmoss/format/elektron/TonverkPresetDetector.java @@ -279,10 +279,12 @@ private ISampleZone createMappedZone (final File file, final TonverkSampleSlot s zone.setKeyRoot (pitch); zone.setTuning (pitch - keyCenter); - if (slot.trimStart != null && slot.trimStart.intValue () >= 0) - zone.setStart (slot.trimStart.intValue ()); - if (slot.trimEnd != null && slot.trimEnd.intValue () >= 0) - zone.setStop (slot.trimEnd.intValue ()); + // A mapping slot without explicit trim points plays the whole sample. Default to the full + // range (0 .. number-of-frames) rather than leaving the model default of -1, which other + // formats would write out verbatim (e.g. the Waldorf QPAT shows a sample start/end of -1). + final int frames = zone.getSampleData ().getAudioMetadata ().getNumberOfSamples (); + zone.setStart (slot.trimStart != null && slot.trimStart.intValue () >= 0 ? slot.trimStart.intValue () : 0); + zone.setStop (slot.trimEnd != null && slot.trimEnd.intValue () >= 0 ? slot.trimEnd.intValue () : frames); if ("Forward".equals (slot.loopMode)) { From 0bbff0a13032455d430be3086bcc4d26d5244eb2 Mon Sep 17 00:00:00 2001 From: Douglas Carmichael Date: Sun, 28 Jun 2026 00:47:19 -0400 Subject: [PATCH 09/13] Default elmulti/eldrum sample trim to the full sample Mirrors the same fix for the .tvpst detector: a mapping slot without explicit trim points plays the whole sample, so default the zone start to 0 and the stop to the sample's frame count rather than leaving the model default of -1 (which destinations such as the Waldorf QPAT would write out verbatim, showing a sample start/end of -1 on the device). Slots that do carry trim-start/trim-end still read their actual values. --- .../format/elektron/TonverkMultiDetector.java | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/main/java/de/mossgrabers/convertwithmoss/format/elektron/TonverkMultiDetector.java b/src/main/java/de/mossgrabers/convertwithmoss/format/elektron/TonverkMultiDetector.java index 61aa2093..62e7667d 100644 --- a/src/main/java/de/mossgrabers/convertwithmoss/format/elektron/TonverkMultiDetector.java +++ b/src/main/java/de/mossgrabers/convertwithmoss/format/elektron/TonverkMultiDetector.java @@ -117,10 +117,12 @@ private List createSampleZone (final TonverkKeyZone zone, final Ton sampleZone.setKeyRoot (zone.pitch); sampleZone.setTuning (zone.pitch - zone.keyCenter); - if (sampleSlot.trimStart != null) - sampleZone.setStart (sampleSlot.trimStart.intValue ()); - if (sampleSlot.trimEnd != null) - sampleZone.setStop (sampleSlot.trimEnd.intValue ()); + // A slot without explicit trim points plays the whole sample; default to the full range + // (0 .. number-of-frames) rather than leaving the model default of -1, which destinations + // such as the Waldorf QPAT would otherwise write out verbatim. + final int frames = sampleZone.getSampleData ().getAudioMetadata ().getNumberOfSamples (); + sampleZone.setStart (sampleSlot.trimStart != null && sampleSlot.trimStart.intValue () >= 0 ? sampleSlot.trimStart.intValue () : 0); + sampleZone.setStop (sampleSlot.trimEnd != null && sampleSlot.trimEnd.intValue () >= 0 ? sampleSlot.trimEnd.intValue () : frames); if ("Forward".equals (sampleSlot.loopMode)) { From afb8fb1e74820c97da2ab1830ff8939392c4b850 Mon Sep 17 00:00:00 2001 From: Douglas Carmichael Date: Sun, 28 Jun 2026 01:08:11 -0400 Subject: [PATCH 10/13] Read loops from the elmulti/eldrum multi-sample mapping The detector parsed a forward loop's start, end and cross-fade but never set the loop type or attached the loop to the sample zone, so every converted instrument silently lost its loop. Set the type to forwards and add the loop to the zone (matching the .tvpst detector), and guard the points against negative values. --- documentation/CHANGELOG.md | 3 +++ .../format/elektron/TonverkMultiDetector.java | 9 ++++++--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/documentation/CHANGELOG.md b/documentation/CHANGELOG.md index b1332458..e3460ae0 100644 --- a/documentation/CHANGELOG.md +++ b/documentation/CHANGELOG.md @@ -9,6 +9,9 @@ * New: Added support for the Synthstrom Deluge instrument format (thanks to Douglas Carmichael). * New: Added support for the Downloadable Sound format (DLS) - read only. * New: Added several new tags for category detection. +* Elektron Tonverk Multisample (thanks to Douglas Carmichael) + * Fixed: Loops were dropped when reading the multi-sample mapping (.elmulti/.eldrum) format - the loop was parsed but never attached to the sample zone, so converted instruments lost their loop. + * Fixed: A mapping slot without explicit sample-trim points read a sample start and end of -1 instead of the whole sample (e.g. a converted Waldorf QPAT then showed a sample start and end of -1 on the device). * FLAC/OGG * Fixed: FLAC or OGG samples stored inside a ZIP archive (e.g. discoDSP Bliss or DecentSampler libraries) could fail to decompress. * Fixed: Stereo (multi-channel) samples stored in a compressed format were truncated to half their length when decompressed while writing to an uncompressed destination. diff --git a/src/main/java/de/mossgrabers/convertwithmoss/format/elektron/TonverkMultiDetector.java b/src/main/java/de/mossgrabers/convertwithmoss/format/elektron/TonverkMultiDetector.java index 62e7667d..ad12ae64 100644 --- a/src/main/java/de/mossgrabers/convertwithmoss/format/elektron/TonverkMultiDetector.java +++ b/src/main/java/de/mossgrabers/convertwithmoss/format/elektron/TonverkMultiDetector.java @@ -20,6 +20,7 @@ import de.mossgrabers.convertwithmoss.core.model.IGroup; import de.mossgrabers.convertwithmoss.core.model.ISampleLoop; import de.mossgrabers.convertwithmoss.core.model.ISampleZone; +import de.mossgrabers.convertwithmoss.core.model.enumeration.LoopType; import de.mossgrabers.convertwithmoss.core.model.implementation.DefaultGroup; import de.mossgrabers.convertwithmoss.core.model.implementation.DefaultSampleLoop; import de.mossgrabers.convertwithmoss.core.settings.MetadataSettingsUI; @@ -127,12 +128,14 @@ private List createSampleZone (final TonverkKeyZone zone, final Ton if ("Forward".equals (sampleSlot.loopMode)) { final ISampleLoop loop = new DefaultSampleLoop (); - if (sampleSlot.loopStart != null) + loop.setType (LoopType.FORWARDS); + if (sampleSlot.loopStart != null && sampleSlot.loopStart.intValue () >= 0) loop.setStart (sampleSlot.loopStart.intValue ()); - if (sampleSlot.loopEnd != null) + if (sampleSlot.loopEnd != null && sampleSlot.loopEnd.intValue () >= 0) loop.setEnd (sampleSlot.loopEnd.intValue ()); - if (sampleSlot.loopCrossfade != null) + if (sampleSlot.loopCrossfade != null && sampleSlot.loopCrossfade.intValue () >= 0) loop.setCrossfadeInSamples (sampleSlot.loopCrossfade.intValue ()); + sampleZone.getLoops ().add (loop); } sampleZones.add (sampleZone); From e0d97c24128f7b837f41bcc24b79638c1f5536c7 Mon Sep 17 00:00:00 2001 From: Douglas Carmichael Date: Sun, 28 Jun 2026 06:24:52 -0400 Subject: [PATCH 11/13] Report empty or corrupt Tonverk presets clearly A preset that parsed without any parameters (e.g. a zero-filled file left by a failed write or transfer) was reported as 'Unknown Tonverk generator machine '%1'' - the machine value was null, so the placeholder was left unsubstituted, and 'unknown machine' was misleading for what is really an empty/corrupt file. Such files are now reported as empty or corrupt with the file name, and the unknown-machine message is guarded against a null value. --- .../format/elektron/TonverkPresetDetector.java | 8 +++++++- src/main/resources/Strings.properties | 1 + 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/main/java/de/mossgrabers/convertwithmoss/format/elektron/TonverkPresetDetector.java b/src/main/java/de/mossgrabers/convertwithmoss/format/elektron/TonverkPresetDetector.java index 44cd7a64..d3e40aa4 100644 --- a/src/main/java/de/mossgrabers/convertwithmoss/format/elektron/TonverkPresetDetector.java +++ b/src/main/java/de/mossgrabers/convertwithmoss/format/elektron/TonverkPresetDetector.java @@ -106,7 +106,13 @@ private IMultisampleSource convertPreset (final File file, final TonverkPresetFi case DRUM -> groups = this.buildDrumGroups (file, preset); default -> { - this.notifier.logError ("IDS_TONVERK_UNKNOWN_MACHINE", preset.param ("gen_machine")); + // A file that parsed without any parameters is empty or corrupt (e.g. a zero-filled + // file left by a failed write), not a real preset with an unsupported machine. + final String genMachine = preset.param ("gen_machine"); + if (preset.parameters.isEmpty ()) + this.notifier.logError ("IDS_TONVERK_EMPTY_OR_CORRUPT", file.getName ()); + else + this.notifier.logError ("IDS_TONVERK_UNKNOWN_MACHINE", genMachine == null ? "" : genMachine); return null; } } diff --git a/src/main/resources/Strings.properties b/src/main/resources/Strings.properties index 26f22914..6568cd0a 100644 --- a/src/main/resources/Strings.properties +++ b/src/main/resources/Strings.properties @@ -550,6 +550,7 @@ IDS_ELEKTRON_RESAMPLE=Compatibility IDS_ELEKTRON_CONVERT_TO_24_48=Re-sample to 24bit/48kHz IDS_TONVERK_UNKNOWN_MACHINE=Unknown Tonverk generator machine '%1'. Only One-Shot, Multi and Drum are supported.\n +IDS_TONVERK_EMPTY_OR_CORRUPT=The Tonverk preset file is empty or corrupt and was skipped: %1\n IDS_TONVERK_SAMPLE_NOT_FOUND=Could not find the sample referenced by the preset: %1\n IDS_TONVERK_DRUM_LIMIT=The source has %1 drums but the Tonverk Drum machine only has %2 voices. The additional drums are dropped.\n IDS_TONVERK_OUTPUT_ENGINE=Output Engine From 268a2e5a8b9daaf2ac5d2de22432da6bbbf19594 Mon Sep 17 00:00:00 2001 From: Douglas Carmichael Date: Sun, 28 Jun 2026 07:38:44 -0400 Subject: [PATCH 12/13] Recalibrate Tonverk envelope times against hardware The envelope time mapping was a cube power law with guessed maxima (attack 1.6 s, decay/release 22 s). Resampling probe presets with known normalized values on real hardware shows the firmware actually uses a warped exponential, seconds = floor * (ceiling/floor)^(normalized^warp), and the old ceilings were far too low - the device takes 3.77 s at attack norm 0.97, already past the old 1.6 s maximum, so every slow attack converted far too fast. Calibrate each stage from the measurements: - attack (linear ramp): floor ~0.01 s, ceiling 4.46 s, warp 0.91 - decay = release (exponential fade to -60 dB, measured identical at 4.64 s vs 4.65 s for norm 0.60): floor ~0.01 s, ceiling 30.8 s, warp 0.53 - hold and delay reuse the attack curve (unprobed) This is the same exponential form already used for the filter cut-off (warp 1), so the mappings are consistent, and the inverse stays exact (a Tonverk-to-Tonverk round-trip remains loss-less). Verified end to end: an SFZ attack of 3.774 s writes as norm 0.970, and reading norm 0.9699 back yields 3.773 s, matching the hardware-measured 3.774 s. --- documentation/CHANGELOG.md | 2 +- .../format/elektron/TonverkValues.java | 151 ++++++++++++------ 2 files changed, 105 insertions(+), 48 deletions(-) diff --git a/documentation/CHANGELOG.md b/documentation/CHANGELOG.md index e3460ae0..67c0a5e4 100644 --- a/documentation/CHANGELOG.md +++ b/documentation/CHANGELOG.md @@ -3,7 +3,7 @@ ## 19.0.0 (unreleased) * New: Improved user interface for long lists of formats. -* New: Added support for the Elektron Tonverk preset (TVPST) format - read (One-Shot, Multi and Drum machines, including amplitude and filter envelopes) and write (Multi or Drum machine). Writing mirrors the device's SD-card layout: the preset is stored as a flat file in 'User/Presets' and its samples in 'User/Multi-sampled Instruments/', referenced by their absolute device path, so the created 'User' folder can be copied straight onto the Tonverk (thanks to Douglas Carmichael). +* New: Added support for the Elektron Tonverk preset (TVPST) format - read (One-Shot, Multi and Drum machines, including amplitude and filter envelopes whose normalized times are mapped to seconds with a warped-exponential curve calibrated against hardware resamples) and write (Multi or Drum machine). Writing mirrors the device's SD-card layout: the preset is stored as a flat file in 'User/Presets' and its samples in 'User/Multi-sampled Instruments/', referenced by their absolute device path, so the created 'User' folder can be copied straight onto the Tonverk (thanks to Douglas Carmichael). * New: The Elektron Tonverk multi-sample mapping format (.elmulti/.eldrum) is now labelled "Elektron Tonverk Multisample" consistently for both reading and writing - it was previously shown as "Elektron Multi" as a source but "Elektron Tonverk" as a destination, the latter being easily confused with "Elektron Tonverk Preset" (thanks to Douglas Carmichael). * New: Added support for the Renoise instrument (XRNI) format (thanks to Douglas Carmichael). * New: Added support for the Synthstrom Deluge instrument format (thanks to Douglas Carmichael). diff --git a/src/main/java/de/mossgrabers/convertwithmoss/format/elektron/TonverkValues.java b/src/main/java/de/mossgrabers/convertwithmoss/format/elektron/TonverkValues.java index d2a58d9c..aec84a88 100644 --- a/src/main/java/de/mossgrabers/convertwithmoss/format/elektron/TonverkValues.java +++ b/src/main/java/de/mossgrabers/convertwithmoss/format/elektron/TonverkValues.java @@ -10,45 +10,80 @@ * by the ConvertWithMoss model (envelope times in seconds, filter cut-off in Hertz). *

* The Tonverk firmware uses internal, non-published curves to map these normalized values to times - * and frequencies. Since those curves are not contained in the preset files, the mappings below are - * a power curve for times and an exponential curve for the filter cut-off. The envelope time maxima - * were calibrated against hardware resamples (see the constants below); the firmware was found to - * follow the same power curve, so the seconds mapping is close, not merely a guess. A - * Tonverk-to-Tonverk round-trip is loss-less either way (the inverse functions are exact). All range - * constants are gathered here so they can be tuned in one place. + * and frequencies. They are not contained in the preset files, so the mappings below were + * reverse-engineered by resampling probe presets on real hardware and measuring the resulting + * envelopes. The firmware maps a normalized value to a time with a warped exponential: + * + *

+ * seconds = floor * (ceiling / floor) ^ (normalized ^ warp)
+ * 
+ * + * i.e. a logarithmic interpolation between a per-stage floor (the time at normalized 0) and ceiling + * (the time at normalized 1), with the control value first warped by a power. This is the same + * exponential form already used for the filter cut-off (where warp = 1), so the two + * mappings are consistent. The inverse is exact, hence a Tonverk-to-Tonverk round-trip is loss-less. + *

+ * Calibration (2026-06-28) against Tonverk resamples of probe presets with known normalized values: + *

    + *
  • Attack is a linear amplitude ramp. Three probes (norm 0.25 -> 0.056 s, 0.50 -> 0.256 s, + * 0.97 -> 3.77 s) fit floor ~0.01 s, ceiling ~4.46 s, warp ~0.91 (within 0.2 %).
  • + *
  • Decay and release are an exponential fade; their time is measured to -60 dB. They were found + * to share one curve (decay and release both gave 4.64 s at norm 0.60). Three probes (norm 0.30 -> + * 0.72 s, 0.60 -> 4.65 s, 0.90 -> 19.96 s) fit floor ~0.01 s, ceiling ~30.8 s, warp ~0.53.
  • + *
  • Hold and delay were not probed; they reuse the attack curve (delay keeps its 4 s ceiling).
  • + *
+ * Earlier guesses (a cube law with attack 1.6 s / 8 s and release 22 s / 24 s ceilings) had both the + * wrong shape and the wrong ceilings - the hardware demonstrably takes 3.77 s at attack norm 0.97, + * already past the old 1.6 s maximum. All range constants are gathered here so they can be tuned in + * one place. * * @author Jürgen Moßgraber */ public final class TonverkValues { - // Envelope time maxima (the seconds reached at normalized value 1.0). Hardware-calibrated - // 2026-06-27 against Tonverk resamples of presets with known normalized values: the firmware - // follows the same seconds = max * normalized^3 cube law the writer uses, so each maximum - // below is the device's measured time at normalized 1.0. Attack is a linear ramp topping out - // near 1.6 s (two probes agree: norm 0.63 -> 0.40 s and norm 0.79 -> 0.78 s, both implying - // max ~1.58 s). Decay/release are an exponential fade whose time to -60 dB tops out near 22 s - // (norm 0.63 -> ~5.5 s). Earlier guesses (attack 8 s, release 24 s) made attacks 5x too fast. - - /** Maximum attack time in seconds (normalized value 1.0). */ - private static final double ATTACK_MAX_SECONDS = 1.6; - /** Maximum hold time in seconds (normalized value 1.0); shares the attack scale (unprobed). */ - private static final double HOLD_MAX_SECONDS = 1.6; - /** Maximum decay time in seconds (normalized value 1.0); shares the release scale (unprobed). */ - private static final double DECAY_MAX_SECONDS = 22.0; - /** Maximum release time in seconds (normalized value 1.0). */ - private static final double RELEASE_MAX_SECONDS = 22.0; - /** Maximum delay time in seconds (normalized value 1.0). */ - private static final double DELAY_MAX_SECONDS = 4.0; - /** - * The exponent of the power curve used for all envelope times. A value > 1 gives finer control - * for short times. seconds = max * normalized^curve. - */ - private static final double TIME_CURVE = 3.0; + // Per-stage envelope time curves: the floor (seconds at normalized 0), the ceiling (seconds at + // normalized 1) and the warp exponent applied to the normalized value. See the class comment for + // the calibration measurements. + + /** Delay time floor in seconds (normalized 0); unprobed, reuses the attack shape. */ + private static final double DELAY_FLOOR_SECONDS = 0.010; + /** Delay time ceiling in seconds (normalized 1); unprobed, reuses the attack shape. */ + private static final double DELAY_CEILING_SECONDS = 4.0; + /** Delay time warp exponent; unprobed, reuses the attack shape. */ + private static final double DELAY_WARP = 0.911; + + /** Attack time floor in seconds (normalized 0); hardware-calibrated. */ + private static final double ATTACK_FLOOR_SECONDS = 0.010; + /** Attack time ceiling in seconds (normalized 1); hardware-calibrated. */ + private static final double ATTACK_CEILING_SECONDS = 4.46; + /** Attack time warp exponent; hardware-calibrated. */ + private static final double ATTACK_WARP = 0.911; + + /** Hold time floor in seconds (normalized 0); unprobed, reuses the attack shape. */ + private static final double HOLD_FLOOR_SECONDS = 0.010; + /** Hold time ceiling in seconds (normalized 1); unprobed, reuses the attack shape. */ + private static final double HOLD_CEILING_SECONDS = 4.46; + /** Hold time warp exponent; unprobed, reuses the attack shape. */ + private static final double HOLD_WARP = 0.911; + + /** Decay time floor in seconds (normalized 0); shares the release curve. */ + private static final double DECAY_FLOOR_SECONDS = 0.011; + /** Decay time ceiling in seconds (normalized 1); shares the release curve. */ + private static final double DECAY_CEILING_SECONDS = 30.8; + /** Decay time warp exponent; shares the release curve. */ + private static final double DECAY_WARP = 0.533; + + /** Release time floor in seconds (normalized 0); hardware-calibrated. */ + private static final double RELEASE_FLOOR_SECONDS = 0.011; + /** Release time ceiling in seconds (normalized 1); hardware-calibrated. */ + private static final double RELEASE_CEILING_SECONDS = 30.8; + /** Release time warp exponent; hardware-calibrated. */ + private static final double RELEASE_WARP = 0.533; /** Minimum filter cut-off frequency in Hertz (normalized value 0.0). */ - private static final double MIN_CUTOFF_HZ = 20.0; + private static final double MIN_CUTOFF_HZ = 20.0; /** Maximum filter cut-off frequency in Hertz (normalized value 1.0). */ - private static final double MAX_CUTOFF_HZ = 20000.0; + private static final double MAX_CUTOFF_HZ = 20000.0; private TonverkValues () @@ -65,7 +100,7 @@ private TonverkValues () */ public static double normalizedToDelayTime (final double normalized) { - return normalizedToTime (normalized, DELAY_MAX_SECONDS); + return normalizedToTime (normalized, DELAY_FLOOR_SECONDS, DELAY_CEILING_SECONDS, DELAY_WARP); } @@ -77,7 +112,7 @@ public static double normalizedToDelayTime (final double normalized) */ public static double delayTimeToNormalized (final double seconds) { - return timeToNormalized (seconds, DELAY_MAX_SECONDS); + return timeToNormalized (seconds, DELAY_FLOOR_SECONDS, DELAY_CEILING_SECONDS, DELAY_WARP); } @@ -89,7 +124,7 @@ public static double delayTimeToNormalized (final double seconds) */ public static double normalizedToAttackTime (final double normalized) { - return normalizedToTime (normalized, ATTACK_MAX_SECONDS); + return normalizedToTime (normalized, ATTACK_FLOOR_SECONDS, ATTACK_CEILING_SECONDS, ATTACK_WARP); } @@ -101,7 +136,7 @@ public static double normalizedToAttackTime (final double normalized) */ public static double attackTimeToNormalized (final double seconds) { - return timeToNormalized (seconds, ATTACK_MAX_SECONDS); + return timeToNormalized (seconds, ATTACK_FLOOR_SECONDS, ATTACK_CEILING_SECONDS, ATTACK_WARP); } @@ -113,7 +148,7 @@ public static double attackTimeToNormalized (final double seconds) */ public static double normalizedToHoldTime (final double normalized) { - return normalizedToTime (normalized, HOLD_MAX_SECONDS); + return normalizedToTime (normalized, HOLD_FLOOR_SECONDS, HOLD_CEILING_SECONDS, HOLD_WARP); } @@ -125,7 +160,7 @@ public static double normalizedToHoldTime (final double normalized) */ public static double holdTimeToNormalized (final double seconds) { - return timeToNormalized (seconds, HOLD_MAX_SECONDS); + return timeToNormalized (seconds, HOLD_FLOOR_SECONDS, HOLD_CEILING_SECONDS, HOLD_WARP); } @@ -137,7 +172,7 @@ public static double holdTimeToNormalized (final double seconds) */ public static double normalizedToDecayTime (final double normalized) { - return normalizedToTime (normalized, DECAY_MAX_SECONDS); + return normalizedToTime (normalized, DECAY_FLOOR_SECONDS, DECAY_CEILING_SECONDS, DECAY_WARP); } @@ -149,7 +184,7 @@ public static double normalizedToDecayTime (final double normalized) */ public static double decayTimeToNormalized (final double seconds) { - return timeToNormalized (seconds, DECAY_MAX_SECONDS); + return timeToNormalized (seconds, DECAY_FLOOR_SECONDS, DECAY_CEILING_SECONDS, DECAY_WARP); } @@ -161,7 +196,7 @@ public static double decayTimeToNormalized (final double seconds) */ public static double normalizedToReleaseTime (final double normalized) { - return normalizedToTime (normalized, RELEASE_MAX_SECONDS); + return normalizedToTime (normalized, RELEASE_FLOOR_SECONDS, RELEASE_CEILING_SECONDS, RELEASE_WARP); } @@ -173,7 +208,7 @@ public static double normalizedToReleaseTime (final double normalized) */ public static double releaseTimeToNormalized (final double seconds) { - return timeToNormalized (seconds, RELEASE_MAX_SECONDS); + return timeToNormalized (seconds, RELEASE_FLOOR_SECONDS, RELEASE_CEILING_SECONDS, RELEASE_WARP); } @@ -218,18 +253,40 @@ public static double clampNormalized (final double normalized) } - private static double normalizedToTime (final double normalized, final double maxSeconds) + /** + * Map a normalized value to a time using the firmware's warped-exponential curve: + * seconds = floor * (ceiling / floor) ^ (normalized ^ warp). + * + * @param normalized The normalized value [0..1] + * @param floorSeconds The time in seconds at normalized 0 + * @param ceilingSeconds The time in seconds at normalized 1 + * @param warp The exponent applied to the normalized value before the exponential + * @return The time in seconds + */ + private static double normalizedToTime (final double normalized, final double floorSeconds, final double ceilingSeconds, final double warp) { - return maxSeconds * Math.pow (clampNormalized (normalized), TIME_CURVE); + final double clamped = clampNormalized (normalized); + return floorSeconds * Math.pow (ceilingSeconds / floorSeconds, Math.pow (clamped, warp)); } - private static double timeToNormalized (final double seconds, final double maxSeconds) + /** + * Inverse of {@link #normalizedToTime}: map a time back to a normalized value. Times at or below + * the floor map to 0, times at or above the ceiling map to 1. + * + * @param seconds The time in seconds + * @param floorSeconds The time in seconds at normalized 0 + * @param ceilingSeconds The time in seconds at normalized 1 + * @param warp The exponent applied to the normalized value before the exponential + * @return The normalized value [0..1] + */ + private static double timeToNormalized (final double seconds, final double floorSeconds, final double ceilingSeconds, final double warp) { - if (seconds <= 0) + if (seconds <= floorSeconds) return 0; - if (seconds >= maxSeconds) + if (seconds >= ceilingSeconds) return 1; - return Math.pow (seconds / maxSeconds, 1.0 / TIME_CURVE); + final double exponent = Math.log (seconds / floorSeconds) / Math.log (ceilingSeconds / floorSeconds); + return Math.pow (exponent, 1.0 / warp); } } From 57e8831a111701f633c44ae7488f6aa4fc6a6a5f Mon Sep 17 00:00:00 2001 From: Douglas Carmichael Date: Mon, 29 Jun 2026 09:30:46 -0400 Subject: [PATCH 13/13] Elektron Tonverk: document format capabilities in the supported-features sheet Fill the SupportedFeaturesSampleFormats.ods rows for the new Elektron Tonverk Preset (.tvpst) read and write support (metadata, key/velocity ranges, sample trim, tuning, gain, panning, forward loops, amplitude AHD/ADSR envelope, and the multi-mode filter with its envelope), and rename the existing multi-sample mapping row from "Elektron Tonverk" to "Elektron Tonverk Multisample" to match its new display name. --- .../SupportedFeaturesSampleFormats.ods | Bin 43778 -> 44166 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/documentation/SupportedFeaturesSampleFormats.ods b/documentation/SupportedFeaturesSampleFormats.ods index 161e16a0b804539a89c20339031214c884ac9830..be52bfaa20533c4c19af2bb4e50c79369b1f3571 100644 GIT binary patch delta 17646 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%0ApFf~jlb%1{#babl~LS- z+U@ocg}>&pZMtcJw`djjXvAMXsfd_6vIA7Lyb<9{r@t<#h}$pH zMSLWK$)qodR_e=677Esq2P4;O{IUTnxGf1C{?VNLllu-D+ z)9?DCbO1?AGA$cF1pywC03?XQz_wX_v?hI*a6$qKp7n1ro&0*^{7&CHMw>0!G!&JG z%e%Z-LZ@z$49^QKH&MsGsgZNK-}h1@!d{;T0}fT7A&OVWaY{fI#jj!MczR92KcK_k zPcYfD$Tj@H->*{WI95YC*q1V-CV=(3G^kX1M=sNfQL2&c+Z$d`0Eh+x{-|k{1#V{N zm}>tPt#`P>0Huj4owhJ1mw8VjJzto(Tr^-!TgD3Wu$&;*_UXwY z-AEo~z#_~Rmqp<{N-AM;$WfV~6Hy+m|7=G5_;w{+hveS@ii)qrz78Uzx=M8u$@le# zNtPnoh>sr-p!D3pRS^8w6b=1nir@5^`%-^*%(a6HWF7}cgSz&DpL^u$m16B{Z#kW4 zG4|@t4e-&`->S$lRP5PrQ4Twy>BLhU7(#FlS!!S{tA_u_$tAjt9;pad zm7vV2DtwxM8|2$((7{5zBB$*$h_zE^#I#vM?^WiXbeP7gN)}q#@%^+u^6wwEHjHY4 zk2lMAgaw!HjFL(U*k@(i;YT=cC23$pyUpI6PIONSoFi>##r5i|b$=X;|}M0Dd44H*A_e<^A+qJkwNKDOp-^pv218&Y^RX-T04H z^z}a`Avn}J$GwcKHt+X|_R~$&tzy;c&^%Wdsq&3I;}}_gU-w-#6OKn-W#L;dJz>+gASsJ)M_$EO0PuuYBNP zuTwv9L+zHO(WahV21nlCRIK~`dzo#*wu+0P_mXb0+vurU?MW{R=H$B*5d7%Oq2!$3 z$8BQ%O4^rqq)t38x6SslxxCN+pT}!n^KH3Qq;`LDiez$0&%W&!IXBi$XP7bb(9NW4 z2`6T;pNVEV`>pL+VEc~#S=kGuc3u8b{Y^-r+uZ)AX?;tzmQVA!(8+cjKUsb%?@3wA zW@gsZwOM@mI~MJG%ugi!9+=cvdz;^SHa#syn!mXvzU-6@_LziEl&%L}q!FE+xoa_Xl8vDhmf4?#s{`p<=RwR>c%IANkZ|;GX%W6FP zaqP2nl*bwAqCFAyzn^wLPGGQa>RAKa1f&`fz53s-iXWX3ZVTq^;@(wob!+i1!BX*( zhpSeLm&E^AV!CV1Z>R62<}dcWYkK$idb@x1TlPMCrhv&$*?ynm;XdW@nOaE1Ph8O3(h;E96%>&C%7|er}K0`#tN9T6ah7`Me9ryiosWZur)U=dEJz zb1poZD;~9HvuAhtgDpEu7JF8nI@fWQQN!@`qv>LQe<#oRskA6Qy^1CE|F17nse(@D z6&O^c7V^mPuyy@!YI?beQTq6+%{#Wvj5&7E?2}UD%q3SAcz>L6{*B9RuftKVd{uAn z+q1IEe5u^FkV(1@u1g|q5C6zHdOhCv^&Y{G1?OIKcI@wcqIgjB+4nnlzel+FEqi3aDNCV?WpgqMUl{u-!dKI}j2-OgK z5XX6-+p`sDZEcLxM1?^RSGJwb4rj-1YzJt(R)2@^8Hn^Y!RzKq5xQ1 Bii7|F delta 17248 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