diff --git a/documentation/CHANGELOG.md b/documentation/CHANGELOG.md index e8d5bbaa..67c0a5e4 100644 --- a/documentation/CHANGELOG.md +++ b/documentation/CHANGELOG.md @@ -3,11 +3,15 @@ ## 19.0.0 (unreleased) * New: Improved user interface for long lists of formats. -* New: Added support for the Polyend Tracker (PTI) instrument format (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). * 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/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/documentation/SupportedFeaturesSampleFormats.ods b/documentation/SupportedFeaturesSampleFormats.ods index 161e16a0..be52bfaa 100644 Binary files a/documentation/SupportedFeaturesSampleFormats.ods and b/documentation/SupportedFeaturesSampleFormats.ods differ 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..1e134e3b 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 75% 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..ad12ae64 100644 --- a/src/main/java/de/mossgrabers/convertwithmoss/format/elektron/ElektronMultiDetector.java +++ b/src/main/java/de/mossgrabers/convertwithmoss/format/elektron/TonverkMultiDetector.java @@ -20,13 +20,14 @@ 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; 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,16 +35,16 @@ * * @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"); + super ("Elektron Tonverk Multisample", "Elektron", notifier, new MetadataSettingsUI ("Elektron"), ".elmulti", ".eldrum"); } @@ -56,7 +57,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 +74,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 +82,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,30 +108,34 @@ 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)); 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)) { 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); @@ -145,7 +150,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 +206,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..16420301 --- /dev/null +++ b/src/main/java/de/mossgrabers/convertwithmoss/format/elektron/TonverkPresetCreator.java @@ -0,0 +1,466 @@ +// 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). 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 + */ +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"; + + /** The absolute device folder under which a preset references its samples. */ + private static final String DEVICE_SAMPLE_FOLDER = "/mnt/sdcard/User/Multi-sampled Instruments/"; + /** Sub-path (below the chosen output folder) holding the flat *.tvpst presets. */ + private static final String PRESETS_SUBPATH = "User" + File.separator + "Presets"; + /** Sub-path (below the chosen output folder) holding the per-preset sample folders. */ + private static final String INSTRUMENTS_SUBPATH = "User" + File.separator + "Multi-sampled Instruments"; + + 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); + + // Mirror the device's SD-card layout: the flat preset goes into 'User/Presets', the samples + // into 'User/Multi-sampled Instruments/'. 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", 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; + } + + 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 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); + + // 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 (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 ()) + { + 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..d3e40aa4 --- /dev/null +++ b/src/main/java/de/mossgrabers/convertwithmoss/format/elektron/TonverkPresetDetector.java @@ -0,0 +1,450 @@ +// 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 -> + { + // 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; + } + } + + 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); + + // 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)) + { + 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..ecd0c12a --- /dev/null +++ b/src/main/java/de/mossgrabers/convertwithmoss/format/elektron/TonverkPresetFile.java @@ -0,0 +1,516 @@ +// 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 ("\"") || 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) + { + final String text = value == null ? "" : value; + if (!text.contains ("'")) + return "'" + text + "'"; + return '"' + text.replace ("\"", "\\\"") + '"'; + } + + + 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..aec84a88 --- /dev/null +++ b/src/main/java/de/mossgrabers/convertwithmoss/format/elektron/TonverkValues.java @@ -0,0 +1,292 @@ +// 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. 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 +{ + // 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; + /** 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_FLOOR_SECONDS, DELAY_CEILING_SECONDS, DELAY_WARP); + } + + + /** + * 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_FLOOR_SECONDS, DELAY_CEILING_SECONDS, DELAY_WARP); + } + + + /** + * 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_FLOOR_SECONDS, ATTACK_CEILING_SECONDS, ATTACK_WARP); + } + + + /** + * 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_FLOOR_SECONDS, ATTACK_CEILING_SECONDS, ATTACK_WARP); + } + + + /** + * 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_FLOOR_SECONDS, HOLD_CEILING_SECONDS, HOLD_WARP); + } + + + /** + * 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_FLOOR_SECONDS, HOLD_CEILING_SECONDS, HOLD_WARP); + } + + + /** + * 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_FLOOR_SECONDS, DECAY_CEILING_SECONDS, DECAY_WARP); + } + + + /** + * 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_FLOOR_SECONDS, DECAY_CEILING_SECONDS, DECAY_WARP); + } + + + /** + * 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_FLOOR_SECONDS, RELEASE_CEILING_SECONDS, RELEASE_WARP); + } + + + /** + * 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_FLOOR_SECONDS, RELEASE_CEILING_SECONDS, RELEASE_WARP); + } + + + /** + * 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); + } + + + /** + * 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) + { + final double clamped = clampNormalized (normalized); + return floorSeconds * Math.pow (ceilingSeconds / floorSeconds, Math.pow (clamped, warp)); + } + + + /** + * 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 <= floorSeconds) + return 0; + if (seconds >= ceilingSeconds) + return 1; + final double exponent = Math.log (seconds / floorSeconds) / Math.log (ceilingSeconds / floorSeconds); + return Math.pow (exponent, 1.0 / warp); + } +} diff --git a/src/main/resources/Strings.properties b/src/main/resources/Strings.properties index 1a207681..6568cd0a 100644 --- a/src/main/resources/Strings.properties +++ b/src/main/resources/Strings.properties @@ -549,6 +549,15 @@ 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_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 +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..507dd79f --- /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' +gen_drum_voice0_base = '0' +gen_drum_voice0_width = '1' +gen_drum_voice0_filter_type = '0.9448819' +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' +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' +gen_drum_voice1_base = '0' +gen_drum_voice1_width = '1' +gen_drum_voice1_filter_type = '0' +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' +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.5' +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' +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' +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 = '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' +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 = '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' +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' +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 = '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' +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 = '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' +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' +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..3792cd52 --- /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' +gen_multi_vibrato_speed = '0.19685039' +gen_multi_vibrato_fade = '0.5' +gen_multi_overdrive = '0' +gen_multi_base = '0' +gen_multi_width = '1' +gen_multi_filter_type = '0' +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.5' +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 = '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' +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' +