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

+ * The Tonverk firmware uses internal, non-published curves to map these normalized values to times + * and frequencies. Since those curves are not contained in the preset files, the mappings below are + * documented approximations: a power curve for times and an exponential curve for the filter cut-off. + * They are chosen so that a Tonverk-to-Tonverk round-trip is loss-less (the inverse functions are + * exact), while a conversion to/from a unit-based format (e.g. seconds) is a close approximation. All + * range constants are gathered here so they can be tuned in one place. + * + * @author Jürgen Moßgraber + */ +public final class TonverkValues +{ + /** Maximum attack time in seconds (normalized value 1.0). */ + private static final double ATTACK_MAX_SECONDS = 8.0; + /** Maximum hold time in seconds (normalized value 1.0). */ + private static final double HOLD_MAX_SECONDS = 8.0; + /** Maximum decay time in seconds (normalized value 1.0). */ + private static final double DECAY_MAX_SECONDS = 24.0; + /** Maximum release time in seconds (normalized value 1.0). */ + private static final double RELEASE_MAX_SECONDS = 24.0; + /** Maximum delay time in seconds (normalized value 1.0). */ + private static final double DELAY_MAX_SECONDS = 4.0; + /** + * The exponent of the power curve used for all envelope times. A value > 1 gives finer control + * for short times. seconds = max * normalized^curve. + */ + private static final double TIME_CURVE = 3.0; + + /** Minimum filter cut-off frequency in Hertz (normalized value 0.0). */ + private static final double MIN_CUTOFF_HZ = 20.0; + /** Maximum filter cut-off frequency in Hertz (normalized value 1.0). */ + private static final double MAX_CUTOFF_HZ = 20000.0; + + + private TonverkValues () + { + // Utility class + } + + + /** + * Convert a normalized envelope delay value to seconds. + * + * @param normalized The normalized value [0..1] + * @return The time in seconds + */ + public static double normalizedToDelayTime (final double normalized) + { + return normalizedToTime (normalized, DELAY_MAX_SECONDS); + } + + + /** + * Convert an envelope delay time in seconds to a normalized value. + * + * @param seconds The time in seconds + * @return The normalized value [0..1] + */ + public static double delayTimeToNormalized (final double seconds) + { + return timeToNormalized (seconds, DELAY_MAX_SECONDS); + } + + + /** + * Convert a normalized envelope attack value to seconds. + * + * @param normalized The normalized value [0..1] + * @return The time in seconds + */ + public static double normalizedToAttackTime (final double normalized) + { + return normalizedToTime (normalized, ATTACK_MAX_SECONDS); + } + + + /** + * Convert an envelope attack time in seconds to a normalized value. + * + * @param seconds The time in seconds + * @return The normalized value [0..1] + */ + public static double attackTimeToNormalized (final double seconds) + { + return timeToNormalized (seconds, ATTACK_MAX_SECONDS); + } + + + /** + * Convert a normalized envelope hold value to seconds. + * + * @param normalized The normalized value [0..1] + * @return The time in seconds + */ + public static double normalizedToHoldTime (final double normalized) + { + return normalizedToTime (normalized, HOLD_MAX_SECONDS); + } + + + /** + * Convert an envelope hold time in seconds to a normalized value. + * + * @param seconds The time in seconds + * @return The normalized value [0..1] + */ + public static double holdTimeToNormalized (final double seconds) + { + return timeToNormalized (seconds, HOLD_MAX_SECONDS); + } + + + /** + * Convert a normalized envelope decay value to seconds. + * + * @param normalized The normalized value [0..1] + * @return The time in seconds + */ + public static double normalizedToDecayTime (final double normalized) + { + return normalizedToTime (normalized, DECAY_MAX_SECONDS); + } + + + /** + * Convert an envelope decay time in seconds to a normalized value. + * + * @param seconds The time in seconds + * @return The normalized value [0..1] + */ + public static double decayTimeToNormalized (final double seconds) + { + return timeToNormalized (seconds, DECAY_MAX_SECONDS); + } + + + /** + * Convert a normalized envelope release value to seconds. + * + * @param normalized The normalized value [0..1] + * @return The time in seconds + */ + public static double normalizedToReleaseTime (final double normalized) + { + return normalizedToTime (normalized, RELEASE_MAX_SECONDS); + } + + + /** + * Convert an envelope release time in seconds to a normalized value. + * + * @param seconds The time in seconds + * @return The normalized value [0..1] + */ + public static double releaseTimeToNormalized (final double seconds) + { + return timeToNormalized (seconds, RELEASE_MAX_SECONDS); + } + + + /** + * Convert a normalized filter cut-off value to a frequency in Hertz. + * + * @param normalized The normalized value [0..1] + * @return The frequency in Hertz + */ + public static double normalizedToCutoff (final double normalized) + { + final double clamped = clampNormalized (normalized); + return MIN_CUTOFF_HZ * Math.pow (MAX_CUTOFF_HZ / MIN_CUTOFF_HZ, clamped); + } + + + /** + * Convert a filter cut-off frequency in Hertz to a normalized value. + * + * @param hertz The frequency in Hertz + * @return The normalized value [0..1] + */ + public static double cutoffToNormalized (final double hertz) + { + if (hertz <= MIN_CUTOFF_HZ) + return 0; + if (hertz >= MAX_CUTOFF_HZ) + return 1; + return Math.log (hertz / MIN_CUTOFF_HZ) / Math.log (MAX_CUTOFF_HZ / MIN_CUTOFF_HZ); + } + + + /** + * Clamp a value to the normalized range [0..1]. + * + * @param normalized The value + * @return The clamped value + */ + public static double clampNormalized (final double normalized) + { + return Math.clamp (normalized, 0.0, 1.0); + } + + + private static double normalizedToTime (final double normalized, final double maxSeconds) + { + return maxSeconds * Math.pow (clampNormalized (normalized), TIME_CURVE); + } + + + private static double timeToNormalized (final double seconds, final double maxSeconds) + { + if (seconds <= 0) + return 0; + if (seconds >= maxSeconds) + return 1; + return Math.pow (seconds / maxSeconds, 1.0 / TIME_CURVE); + } +} diff --git a/src/main/java/de/mossgrabers/convertwithmoss/format/ni/kontakt/AbstractNKIMetadataFileHandler.java b/src/main/java/de/mossgrabers/convertwithmoss/format/ni/kontakt/AbstractNKIMetadataFileHandler.java index 4de86e66..9bc6b240 100644 --- a/src/main/java/de/mossgrabers/convertwithmoss/format/ni/kontakt/AbstractNKIMetadataFileHandler.java +++ b/src/main/java/de/mossgrabers/convertwithmoss/format/ni/kontakt/AbstractNKIMetadataFileHandler.java @@ -1011,6 +1011,9 @@ private void readLoopInformation (final Element zoneElement, final ISampleZone s loop.setTuning (12.0 * (Math.log (loopTuning) / Math.log (2.0))); loop.setCrossfadeInSamples (xFadeLength); loop.setType (loopType); + // 'until_release' loops while the key is held and then plays the remainder of the sample + // on release (sustain loop); 'until_end' loops continuously + loop.setLoopUntilRelease (loopMode.equals (this.tags.untilReleaseValue ())); sampleMetadata.addLoop (loop); } } diff --git a/src/main/java/de/mossgrabers/convertwithmoss/format/renoise/RenoiseCreator.java b/src/main/java/de/mossgrabers/convertwithmoss/format/renoise/RenoiseCreator.java index ecf58ee4..d90d57b7 100644 --- a/src/main/java/de/mossgrabers/convertwithmoss/format/renoise/RenoiseCreator.java +++ b/src/main/java/de/mossgrabers/convertwithmoss/format/renoise/RenoiseCreator.java @@ -195,7 +195,9 @@ private void createSampleElement (final Document document, final Element samples final List loops = zone.getLoops (); final ISampleLoop loop = loops.isEmpty () ? null : loops.get (0); XMLUtils.addTextElement (document, sampleElement, RenoiseTag.LOOP_MODE, loopMode (loop)); - XMLUtils.addTextElement (document, sampleElement, RenoiseTag.LOOP_RELEASE, "false"); + // 'LoopRelease' true exits the loop on note-off and plays the remainder of the sample + // (sustain loop); false keeps looping + XMLUtils.addTextElement (document, sampleElement, RenoiseTag.LOOP_RELEASE, loop != null && loop.isLoopUntilRelease () ? "true" : "false"); XMLUtils.addTextElement (document, sampleElement, RenoiseTag.LOOP_START, Integer.toString (loop == null ? 0 : Math.max (0, loop.getStart ()))); XMLUtils.addTextElement (document, sampleElement, RenoiseTag.LOOP_END, Integer.toString (loop == null ? 0 : Math.max (0, loop.getEnd ()))); diff --git a/src/main/java/de/mossgrabers/convertwithmoss/format/renoise/RenoiseDetector.java b/src/main/java/de/mossgrabers/convertwithmoss/format/renoise/RenoiseDetector.java index 7160556d..497f57ee 100644 --- a/src/main/java/de/mossgrabers/convertwithmoss/format/renoise/RenoiseDetector.java +++ b/src/main/java/de/mossgrabers/convertwithmoss/format/renoise/RenoiseDetector.java @@ -267,6 +267,9 @@ private ISampleZone parseSample (final File sourceFile, final Element sampleElem } loop.setStart (XMLUtils.getChildElementIntegerContent (sampleElement, RenoiseTag.LOOP_START, 0)); loop.setEnd (XMLUtils.getChildElementIntegerContent (sampleElement, RenoiseTag.LOOP_END, zone.getStop ())); + // 'LoopRelease' true exits the loop on note-off and plays the remainder of the sample + // (sustain loop); false keeps looping + loop.setLoopUntilRelease ("true".equalsIgnoreCase (XMLUtils.getChildElementContent (sampleElement, RenoiseTag.LOOP_RELEASE))); zone.addLoop (loop); } diff --git a/src/main/java/de/mossgrabers/convertwithmoss/format/sf2/Sf2Creator.java b/src/main/java/de/mossgrabers/convertwithmoss/format/sf2/Sf2Creator.java index 53416841..80475a55 100644 --- a/src/main/java/de/mossgrabers/convertwithmoss/format/sf2/Sf2Creator.java +++ b/src/main/java/de/mossgrabers/convertwithmoss/format/sf2/Sf2Creator.java @@ -373,8 +373,10 @@ else if (sampleType == Sf2SampleDescriptor.RIGHT) instrumentZone.addGenerator (Generator.KEY_RANGE, limitToDefault (sampleZone.getKeyLow (), 0), limitToDefault (sampleZone.getKeyHigh (), 127)); instrumentZone.addGenerator (Generator.VELOCITY_RANGE, limitToDefault (sampleZone.getVelocityLow (), 1), limitToDefault (sampleZone.getVelocityHigh (), 127)); - // Set loop, if any - instrumentZone.addGenerator (Generator.SAMPLE_MODES, sampleZone.getLoops ().isEmpty () ? 0 : 1); + // Set loop, if any: mode 1 loops continuously, mode 3 is a sustain loop (loops until the key + // is released and then plays the remainder of the sample) + final List sampleLoops = sampleZone.getLoops (); + instrumentZone.addGenerator (Generator.SAMPLE_MODES, sampleLoops.isEmpty () ? 0 : (sampleLoops.get (0).isLoopUntilRelease () ? 3 : 1)); // Gain instrumentZone.addGenerator (Generator.INITIAL_ATTENUATION, (int) Math.round (-sampleZone.getGain () * 10.0)); diff --git a/src/main/java/de/mossgrabers/convertwithmoss/format/sf2/Sf2Detector.java b/src/main/java/de/mossgrabers/convertwithmoss/format/sf2/Sf2Detector.java index 292db78b..0e5567cd 100644 --- a/src/main/java/de/mossgrabers/convertwithmoss/format/sf2/Sf2Detector.java +++ b/src/main/java/de/mossgrabers/convertwithmoss/format/sf2/Sf2Detector.java @@ -475,9 +475,13 @@ private static ISampleZone createSampleZone (final Sf2SampleDescriptor sample, f zone.setStop ((int) (sample.getEnd () - sampleStart + sampleEndOffsetInt)); // Set loop, if any - if ((generators.getUnsignedValue (Generator.SAMPLE_MODES).intValue () & 1) > 0) + final int sampleModes = generators.getUnsignedValue (Generator.SAMPLE_MODES).intValue (); + if ((sampleModes & 1) > 0) { final ISampleLoop sampleLoop = new DefaultSampleLoop (); + // Sample mode 3 keeps looping while the key is held and then plays the remainder of + // the sample on release (sustain loop); mode 1 loops continuously + sampleLoop.setLoopUntilRelease ((sampleModes & 2) > 0); final Integer startOffset = generators.getSignedValue (Generator.START_LOOP_ADDRS_OFFSET); final int startOffsetInt = startOffset == null ? 0 : startOffset.intValue (); sampleLoop.setStart ((int) (sample.getLoopStart () - sampleStart + startOffsetInt)); diff --git a/src/main/java/de/mossgrabers/convertwithmoss/format/sfz/SfzCreator.java b/src/main/java/de/mossgrabers/convertwithmoss/format/sfz/SfzCreator.java index 095c58cc..1ea98344 100644 --- a/src/main/java/de/mossgrabers/convertwithmoss/format/sfz/SfzCreator.java +++ b/src/main/java/de/mossgrabers/convertwithmoss/format/sfz/SfzCreator.java @@ -395,8 +395,9 @@ private void createLoops (final StringBuilder buffer, final ISampleZone zone) else { final ISampleLoop sampleLoop = loops.get (0); - // SFZ currently only supports forward looping - addAttribute (buffer, SfzOpcode.LOOP_MODE, "loop_continuous", false); + // 'loop_sustain' loops until the key is released and then plays the remainder of the + // sample; 'loop_continuous' keeps looping + addAttribute (buffer, SfzOpcode.LOOP_MODE, sampleLoop.isLoopUntilRelease () ? "loop_sustain" : "loop_continuous", false); final String type = LOOP_TYPE_MAP.get (sampleLoop.getType ()); // No need to write the default value if (!"forward".equals (type)) diff --git a/src/main/java/de/mossgrabers/convertwithmoss/format/sfz/SfzDetector.java b/src/main/java/de/mossgrabers/convertwithmoss/format/sfz/SfzDetector.java index dd5876b3..451204bb 100644 --- a/src/main/java/de/mossgrabers/convertwithmoss/format/sfz/SfzDetector.java +++ b/src/main/java/de/mossgrabers/convertwithmoss/format/sfz/SfzDetector.java @@ -621,6 +621,9 @@ private void parseLoop (final ISampleZone sampleMetadata) return; case "loop_continuous", "loop_sustain": + // 'loop_sustain' loops until the key is released and then plays the remainder of + // the sample; 'loop_continuous' keeps looping + loop.setLoopUntilRelease ("loop_sustain".equals (loopMode.get ())); final Optional loopType = this.getAttribute (SfzOpcode.LOOP_TYPE); if (loopType.isPresent ()) { diff --git a/src/main/resources/Strings.properties b/src/main/resources/Strings.properties index 1a207681..26f22914 100644 --- a/src/main/resources/Strings.properties +++ b/src/main/resources/Strings.properties @@ -549,6 +549,14 @@ IDS_DS_ADD_FILTER_TO_GROUPS=Add low-pass filter to all groups if none is present IDS_ELEKTRON_RESAMPLE=Compatibility IDS_ELEKTRON_CONVERT_TO_24_48=Re-sample to 24bit/48kHz +IDS_TONVERK_UNKNOWN_MACHINE=Unknown Tonverk generator machine '%1'. Only One-Shot, Multi and Drum are supported.\n +IDS_TONVERK_SAMPLE_NOT_FOUND=Could not find the sample referenced by the preset: %1\n +IDS_TONVERK_DRUM_LIMIT=The source has %1 drums but the Tonverk Drum machine only has %2 voices. The additional drums are dropped.\n +IDS_TONVERK_OUTPUT_ENGINE=Output Engine +IDS_TONVERK_ENGINE_MULTI=Multi-Sample +IDS_TONVERK_ENGINE_DRUM=Drum Kit +IDS_TONVERK_ENGINE_AUTO=Auto (from source) + IDS_KMP_OPTIONS=KMP Options IDS_KMP_USE_KSC=Use KSC files as the input (otherwise only KMP files are used) IDS_KMP_GAIN_12DB=Enable the +12dB option diff --git a/src/main/resources/de/mossgrabers/convertwithmoss/templates/tonverk/drum-template.tvpst b/src/main/resources/de/mossgrabers/convertwithmoss/templates/tonverk/drum-template.tvpst new file mode 100644 index 00000000..b3cd601f --- /dev/null +++ b/src/main/resources/de/mossgrabers/convertwithmoss/templates/tonverk/drum-template.tvpst @@ -0,0 +1,522 @@ +version = 2 +category = 'DRUMS' +tags = [] + +[parameters] +arp_enabled = '0' +arp_speed = '13' +arp_note_length = '14' +arp_mode = '0' +arp_range = '1' +lfo1_speed = '32' +lfo1_multiplier = '1' +lfo1_destination = '' +lfo1_depth = '0' +lfo1_waveform = 'Sine' +lfo1_start_phase = '0' +lfo1_trig_mode = 'Free' +lfo1_smoothing = '0' +lfo2_speed = '32' +lfo2_multiplier = '1' +lfo2_destination = '' +lfo2_depth = '0' +lfo2_waveform = 'Random' +lfo2_start_phase = '0' +lfo2_trig_mode = 'Hold' +lfo2_fade = '0' +lfo2_smoothing = '0' +pitchbend_value = '0' +pitchbend_mod1_destination = '' +pitchbend_mod2_destination = '' +pitchbend_mod3_destination = '' +pitchbend_mod4_destination = '' +pitchbend_mod1_depth = '0' +pitchbend_mod2_depth = '0' +pitchbend_mod3_depth = '0' +pitchbend_mod4_depth = '0' +aftertouch_value = '0' +aftertouch_mod1_destination = '' +aftertouch_mod2_destination = '' +aftertouch_mod3_destination = '' +aftertouch_mod4_destination = '' +aftertouch_mod1_depth = '0' +aftertouch_mod2_depth = '0' +aftertouch_mod3_depth = '0' +aftertouch_mod4_depth = '0' +modwheel_value = '0' +modwheel_mod1_destination = '' +modwheel_mod2_destination = '' +modwheel_mod3_destination = '' +modwheel_mod4_destination = '' +modwheel_mod1_depth = '0' +modwheel_mod2_depth = '0' +modwheel_mod3_depth = '0' +modwheel_mod4_depth = '0' +breath_control_value = '0' +breath_control_mod1_destination = '' +breath_control_mod2_destination = '' +breath_control_mod3_destination = '' +breath_control_mod4_destination = '' +breath_control_mod1_depth = '0' +breath_control_mod2_depth = '0' +breath_control_mod3_depth = '0' +breath_control_mod4_depth = '0' +fx1_machine = '0' +fx1_dirtshaper_drive = '0' +fx1_dirtshaper_rectify = '0.015748031' +fx1_dirtshaper_hpf = '0.503937' +fx1_dirtshaper_lpf = '1' +fx1_dirtshaper_xnoise = '0.1023622' +fx1_dirtshaper_noise_freq = '0.503937' +fx1_dirtshaper_noise_reso = '0' +fx1_dirtshaper_mix = '1' +fx2_machine = '0' +fx2_comb_frequency = '0.75' +fx2_comb_feedback = '0.6640625' +fx2_comb_damping = '1' +fx2_comb_mod_start_phase = '-0.0027855153' +fx2_comb_detune = '0.44085938' +fx2_comb_mod_rate = '0.27559054' +fx2_comb_mod_depth = '0.48818898' +fx2_comb_mix = '0.89' +midi_machine = '0' +gen_machine = '2' +gen_drum_note_priority = '0' +gen_drum_note_reuse_voices = '1' +gen_drum_velocity_curve = '3' +gen_drum_tune = '0.5' +gen_drum_gain = '1' +gen_drum_filter_routing = '5' +gen_drum_voice0_tune = '0.5' +gen_drum_voice0_play_mode = '1' +gen_drum_voice0_sample_start = '0' +gen_drum_voice0_play_length = '1' +gen_drum_voice0_loop_point = '-0.00008333333' +gen_drum_voice0_overdrive = '0.24409449' +gen_drum_voice0_base = '0' +gen_drum_voice0_width = '1' +gen_drum_voice0_filter_type = '0.9448819' +gen_drum_voice0_filter_frequency = '0.19669291' +gen_drum_voice0_filter_resonance = '0.53543305' +gen_drum_voice0_filter_env_delay = '0' +gen_drum_voice0_filter_env_attack = '0' +gen_drum_voice0_filter_env_decay = '0.42519686' +gen_drum_voice0_filter_env_sustain = '0' +gen_drum_voice0_filter_env_release = '0.503937' +gen_drum_voice0_filter_env_depth = '0.5' +gen_drum_voice0_filter_env_reset = '0' +gen_drum_voice0_stereo_spread = '0.5' +gen_drum_voice0_amp_mode = '1' +gen_drum_voice0_amp_env_attack = '0.031496063' +gen_drum_voice0_amp_env_hold = '0.2913386' +gen_drum_voice0_amp_env_decay = '0.31496063' +gen_drum_voice0_amp_env_sustain = '1' +gen_drum_voice0_amp_env_release = '0.2519685' +gen_drum_voice0_amp_env_reset = '1' +gen_drum_voice0_volume = '0.78740156' +gen_drum_voice0_pan = '0.5' +gen_drum_voice0_lfo1_speed = '0.75' +gen_drum_voice0_lfo1_multiplier = '1' +gen_drum_voice0_lfo1_fade = '0.5' +gen_drum_voice0_lfo1_wave = 'Sine' +gen_drum_voice0_lfo1_start_phase = '0' +gen_drum_voice0_lfo1_smoothing = '0' +gen_drum_voice0_lfo1_trig_mode = 'Free' +gen_drum_voice0_lfo1_destination = '0' +gen_drum_voice0_lfo1_depth = '0.5' +gen_drum_voice0_lfo2_speed = '0.75' +gen_drum_voice0_lfo2_multiplier = '3' +gen_drum_voice0_lfo2_fade = '0.5' +gen_drum_voice0_lfo2_wave = 'Triangle' +gen_drum_voice0_lfo2_start_phase = '0' +gen_drum_voice0_lfo2_smoothing = '0' +gen_drum_voice0_lfo2_trig_mode = 'Free' +gen_drum_voice0_lfo2_destination = '0' +gen_drum_voice0_lfo2_depth = '0.5' +gen_drum_voice0_mod_env_delay = '0' +gen_drum_voice0_mod_env_attack = '0' +gen_drum_voice0_mod_env_decay = '0.503937' +gen_drum_voice0_mod_env_sustain = '0' +gen_drum_voice0_mod_env_release = '0.503937' +gen_drum_voice0_mod_env_reset = '0' +gen_drum_voice0_mod_env_destination = '0' +gen_drum_voice0_mod_env_depth = '0.5' +gen_drum_voice1_tune = '0.5' +gen_drum_voice1_play_mode = '1' +gen_drum_voice1_sample_start = '0' +gen_drum_voice1_play_length = '1' +gen_drum_voice1_loop_point = '-0.00008333333' +gen_drum_voice1_overdrive = '0.08661418' +gen_drum_voice1_base = '0' +gen_drum_voice1_width = '1' +gen_drum_voice1_filter_type = '0' +gen_drum_voice1_filter_frequency = '0.91322833' +gen_drum_voice1_filter_resonance = '0' +gen_drum_voice1_filter_env_delay = '0' +gen_drum_voice1_filter_env_attack = '0' +gen_drum_voice1_filter_env_decay = '0.503937' +gen_drum_voice1_filter_env_sustain = '0' +gen_drum_voice1_filter_env_release = '0.503937' +gen_drum_voice1_filter_env_depth = '0.5' +gen_drum_voice1_filter_env_reset = '0' +gen_drum_voice1_stereo_spread = '0.5' +gen_drum_voice1_amp_mode = '1' +gen_drum_voice1_amp_env_attack = '0' +gen_drum_voice1_amp_env_hold = '0' +gen_drum_voice1_amp_env_decay = '1' +gen_drum_voice1_amp_env_sustain = '1' +gen_drum_voice1_amp_env_release = '0.2519685' +gen_drum_voice1_amp_env_reset = '1' +gen_drum_voice1_volume = '0.8267717' +gen_drum_voice1_pan = '0.5' +gen_drum_voice1_lfo1_speed = '0.75' +gen_drum_voice1_lfo1_multiplier = '1' +gen_drum_voice1_lfo1_fade = '0.5' +gen_drum_voice1_lfo1_wave = 'Random' +gen_drum_voice1_lfo1_start_phase = '0' +gen_drum_voice1_lfo1_smoothing = '0' +gen_drum_voice1_lfo1_trig_mode = 'Hold' +gen_drum_voice1_lfo1_destination = '0' +gen_drum_voice1_lfo1_depth = '0.50003904' +gen_drum_voice1_lfo2_speed = '0.75' +gen_drum_voice1_lfo2_multiplier = '3' +gen_drum_voice1_lfo2_fade = '0.5' +gen_drum_voice1_lfo2_wave = 'Triangle' +gen_drum_voice1_lfo2_start_phase = '0' +gen_drum_voice1_lfo2_smoothing = '0' +gen_drum_voice1_lfo2_trig_mode = 'Free' +gen_drum_voice1_lfo2_destination = '0' +gen_drum_voice1_lfo2_depth = '0.5' +gen_drum_voice1_mod_env_delay = '0' +gen_drum_voice1_mod_env_attack = '0' +gen_drum_voice1_mod_env_decay = '0.503937' +gen_drum_voice1_mod_env_sustain = '0' +gen_drum_voice1_mod_env_release = '0.503937' +gen_drum_voice1_mod_env_reset = '0' +gen_drum_voice1_mod_env_destination = '0' +gen_drum_voice1_mod_env_depth = '0.5' +gen_drum_voice2_tune = '0.5' +gen_drum_voice2_play_mode = '1' +gen_drum_voice2_sample_start = '0.00066666666' +gen_drum_voice2_play_length = '1' +gen_drum_voice2_loop_point = '-0.00008333333' +gen_drum_voice2_overdrive = '0.87401575' +gen_drum_voice2_base = '0.46456692' +gen_drum_voice2_width = '1' +gen_drum_voice2_filter_type = '0' +gen_drum_voice2_filter_frequency = '1' +gen_drum_voice2_filter_resonance = '0' +gen_drum_voice2_filter_env_delay = '0' +gen_drum_voice2_filter_env_attack = '0' +gen_drum_voice2_filter_env_decay = '0.503937' +gen_drum_voice2_filter_env_sustain = '0' +gen_drum_voice2_filter_env_release = '0.503937' +gen_drum_voice2_filter_env_depth = '0.5' +gen_drum_voice2_filter_env_reset = '0' +gen_drum_voice2_stereo_spread = '0.6796875' +gen_drum_voice2_amp_mode = '1' +gen_drum_voice2_amp_env_attack = '0' +gen_drum_voice2_amp_env_hold = '1' +gen_drum_voice2_amp_env_decay = '0.3464567' +gen_drum_voice2_amp_env_sustain = '1' +gen_drum_voice2_amp_env_release = '0.2519685' +gen_drum_voice2_amp_env_reset = '1' +gen_drum_voice2_volume = '0.72440946' +gen_drum_voice2_pan = '0.515625' +gen_drum_voice2_lfo1_speed = '0.75' +gen_drum_voice2_lfo1_multiplier = '1' +gen_drum_voice2_lfo1_fade = '0.5' +gen_drum_voice2_lfo1_wave = 'Sine' +gen_drum_voice2_lfo1_start_phase = '0' +gen_drum_voice2_lfo1_smoothing = '0' +gen_drum_voice2_lfo1_trig_mode = 'Free' +gen_drum_voice2_lfo1_destination = '0' +gen_drum_voice2_lfo1_depth = '0.5' +gen_drum_voice2_lfo2_speed = '0.75' +gen_drum_voice2_lfo2_multiplier = '3' +gen_drum_voice2_lfo2_fade = '0.5' +gen_drum_voice2_lfo2_wave = 'Triangle' +gen_drum_voice2_lfo2_start_phase = '0' +gen_drum_voice2_lfo2_smoothing = '0' +gen_drum_voice2_lfo2_trig_mode = 'Free' +gen_drum_voice2_lfo2_destination = '0' +gen_drum_voice2_lfo2_depth = '0.5' +gen_drum_voice2_mod_env_delay = '0' +gen_drum_voice2_mod_env_attack = '0' +gen_drum_voice2_mod_env_decay = '0.503937' +gen_drum_voice2_mod_env_sustain = '0' +gen_drum_voice2_mod_env_release = '0.503937' +gen_drum_voice2_mod_env_reset = '0' +gen_drum_voice2_mod_env_destination = '0' +gen_drum_voice2_mod_env_depth = '0.5' +gen_drum_voice3_tune = '0.5' +gen_drum_voice3_play_mode = '1' +gen_drum_voice3_sample_start = '0' +gen_drum_voice3_play_length = '1' +gen_drum_voice3_loop_point = '-0.00008333333' +gen_drum_voice3_overdrive = '0' +gen_drum_voice3_base = '0' +gen_drum_voice3_width = '1' +gen_drum_voice3_filter_type = '0' +gen_drum_voice3_filter_frequency = '1' +gen_drum_voice3_filter_resonance = '0' +gen_drum_voice3_filter_env_delay = '0' +gen_drum_voice3_filter_env_attack = '0' +gen_drum_voice3_filter_env_decay = '0.503937' +gen_drum_voice3_filter_env_sustain = '0' +gen_drum_voice3_filter_env_release = '0.503937' +gen_drum_voice3_filter_env_depth = '0.5' +gen_drum_voice3_filter_env_reset = '0' +gen_drum_voice3_stereo_spread = '0.5' +gen_drum_voice3_amp_mode = '1' +gen_drum_voice3_amp_env_attack = '0' +gen_drum_voice3_amp_env_hold = '0.03937008' +gen_drum_voice3_amp_env_decay = '0.1496063' +gen_drum_voice3_amp_env_sustain = '1' +gen_drum_voice3_amp_env_release = '0.2519685' +gen_drum_voice3_amp_env_reset = '1' +gen_drum_voice3_volume = '0.86614174' +gen_drum_voice3_pan = '0.5' +gen_drum_voice3_lfo1_speed = '0.75' +gen_drum_voice3_lfo1_multiplier = '1' +gen_drum_voice3_lfo1_fade = '0.5' +gen_drum_voice3_lfo1_wave = 'Sine' +gen_drum_voice3_lfo1_start_phase = '0' +gen_drum_voice3_lfo1_smoothing = '0' +gen_drum_voice3_lfo1_trig_mode = 'Free' +gen_drum_voice3_lfo1_destination = '0' +gen_drum_voice3_lfo1_depth = '0.5' +gen_drum_voice3_lfo2_speed = '0.75' +gen_drum_voice3_lfo2_multiplier = '3' +gen_drum_voice3_lfo2_fade = '0.5' +gen_drum_voice3_lfo2_wave = 'Triangle' +gen_drum_voice3_lfo2_start_phase = '0' +gen_drum_voice3_lfo2_smoothing = '0' +gen_drum_voice3_lfo2_trig_mode = 'Free' +gen_drum_voice3_lfo2_destination = '0' +gen_drum_voice3_lfo2_depth = '0.5' +gen_drum_voice3_mod_env_delay = '0' +gen_drum_voice3_mod_env_attack = '0' +gen_drum_voice3_mod_env_decay = '0.503937' +gen_drum_voice3_mod_env_sustain = '0' +gen_drum_voice3_mod_env_release = '0.503937' +gen_drum_voice3_mod_env_reset = '0' +gen_drum_voice3_mod_env_destination = '0' +gen_drum_voice3_mod_env_depth = '0.5' +gen_drum_voice4_tune = '0.50275' +gen_drum_voice4_play_mode = '1' +gen_drum_voice4_sample_start = '0.17074999' +gen_drum_voice4_play_length = '1' +gen_drum_voice4_loop_point = '0.66791666' +gen_drum_voice4_overdrive = '0.7007874' +gen_drum_voice4_base = '0.43307087' +gen_drum_voice4_width = '1' +gen_drum_voice4_filter_type = '0' +gen_drum_voice4_filter_frequency = '1' +gen_drum_voice4_filter_resonance = '0' +gen_drum_voice4_filter_env_delay = '0' +gen_drum_voice4_filter_env_attack = '0' +gen_drum_voice4_filter_env_decay = '0.503937' +gen_drum_voice4_filter_env_sustain = '0' +gen_drum_voice4_filter_env_release = '0.503937' +gen_drum_voice4_filter_env_depth = '0.5' +gen_drum_voice4_filter_env_reset = '0' +gen_drum_voice4_stereo_spread = '0.4921875' +gen_drum_voice4_amp_mode = '1' +gen_drum_voice4_amp_env_attack = '0' +gen_drum_voice4_amp_env_hold = '1' +gen_drum_voice4_amp_env_decay = '0.68503934' +gen_drum_voice4_amp_env_sustain = '1' +gen_drum_voice4_amp_env_release = '0.2519685' +gen_drum_voice4_amp_env_reset = '1' +gen_drum_voice4_volume = '0.77952754' +gen_drum_voice4_pan = '0.5' +gen_drum_voice4_lfo1_speed = '0.75' +gen_drum_voice4_lfo1_multiplier = '1' +gen_drum_voice4_lfo1_fade = '0.5' +gen_drum_voice4_lfo1_wave = 'Triangle' +gen_drum_voice4_lfo1_start_phase = '0' +gen_drum_voice4_lfo1_smoothing = '0' +gen_drum_voice4_lfo1_trig_mode = 'Free' +gen_drum_voice4_lfo1_destination = '19' +gen_drum_voice4_lfo1_depth = '0.7293359' +gen_drum_voice4_lfo2_speed = '0.6735156' +gen_drum_voice4_lfo2_multiplier = '3' +gen_drum_voice4_lfo2_fade = '0.5' +gen_drum_voice4_lfo2_wave = 'Sine' +gen_drum_voice4_lfo2_start_phase = '0' +gen_drum_voice4_lfo2_smoothing = '0' +gen_drum_voice4_lfo2_trig_mode = 'Free' +gen_drum_voice4_lfo2_destination = '3' +gen_drum_voice4_lfo2_depth = '0.71304685' +gen_drum_voice4_mod_env_delay = '0' +gen_drum_voice4_mod_env_attack = '0' +gen_drum_voice4_mod_env_decay = '0.503937' +gen_drum_voice4_mod_env_sustain = '0' +gen_drum_voice4_mod_env_release = '0.503937' +gen_drum_voice4_mod_env_reset = '0' +gen_drum_voice4_mod_env_destination = '0' +gen_drum_voice4_mod_env_depth = '0.5' +gen_drum_voice5_tune = '0.5' +gen_drum_voice5_play_mode = '1' +gen_drum_voice5_sample_start = '0' +gen_drum_voice5_play_length = '1' +gen_drum_voice5_loop_point = '-0.00008333333' +gen_drum_voice5_overdrive = '0' +gen_drum_voice5_base = '0' +gen_drum_voice5_width = '1' +gen_drum_voice5_filter_type = '0' +gen_drum_voice5_filter_frequency = '1' +gen_drum_voice5_filter_resonance = '0' +gen_drum_voice5_filter_env_delay = '0' +gen_drum_voice5_filter_env_attack = '0' +gen_drum_voice5_filter_env_decay = '0.503937' +gen_drum_voice5_filter_env_sustain = '0' +gen_drum_voice5_filter_env_release = '0.503937' +gen_drum_voice5_filter_env_depth = '0.5' +gen_drum_voice5_filter_env_reset = '0' +gen_drum_voice5_stereo_spread = '0.5' +gen_drum_voice5_amp_mode = '1' +gen_drum_voice5_amp_env_attack = '0' +gen_drum_voice5_amp_env_hold = '0' +gen_drum_voice5_amp_env_decay = '1' +gen_drum_voice5_amp_env_sustain = '1' +gen_drum_voice5_amp_env_release = '0.2519685' +gen_drum_voice5_amp_env_reset = '1' +gen_drum_voice5_volume = '0.78740156' +gen_drum_voice5_pan = '0.5' +gen_drum_voice5_lfo1_speed = '0.75' +gen_drum_voice5_lfo1_multiplier = '1' +gen_drum_voice5_lfo1_fade = '0.5' +gen_drum_voice5_lfo1_wave = 'Sine' +gen_drum_voice5_lfo1_start_phase = '0' +gen_drum_voice5_lfo1_smoothing = '0' +gen_drum_voice5_lfo1_trig_mode = 'Free' +gen_drum_voice5_lfo1_destination = '0' +gen_drum_voice5_lfo1_depth = '0.5' +gen_drum_voice5_lfo2_speed = '0.75' +gen_drum_voice5_lfo2_multiplier = '3' +gen_drum_voice5_lfo2_fade = '0.5' +gen_drum_voice5_lfo2_wave = 'Triangle' +gen_drum_voice5_lfo2_start_phase = '0' +gen_drum_voice5_lfo2_smoothing = '0' +gen_drum_voice5_lfo2_trig_mode = 'Free' +gen_drum_voice5_lfo2_destination = '0' +gen_drum_voice5_lfo2_depth = '0.5' +gen_drum_voice5_mod_env_delay = '0' +gen_drum_voice5_mod_env_attack = '0' +gen_drum_voice5_mod_env_decay = '0.503937' +gen_drum_voice5_mod_env_sustain = '0' +gen_drum_voice5_mod_env_release = '0.503937' +gen_drum_voice5_mod_env_reset = '0' +gen_drum_voice5_mod_env_destination = '0' +gen_drum_voice5_mod_env_depth = '0.5' +gen_drum_voice6_tune = '0.5' +gen_drum_voice6_play_mode = '1' +gen_drum_voice6_sample_start = '0' +gen_drum_voice6_play_length = '1' +gen_drum_voice6_loop_point = '-0.00008333333' +gen_drum_voice6_overdrive = '0.08661418' +gen_drum_voice6_base = '0' +gen_drum_voice6_width = '1' +gen_drum_voice6_filter_type = '0' +gen_drum_voice6_filter_frequency = '1' +gen_drum_voice6_filter_resonance = '0' +gen_drum_voice6_filter_env_delay = '0' +gen_drum_voice6_filter_env_attack = '0' +gen_drum_voice6_filter_env_decay = '0.503937' +gen_drum_voice6_filter_env_sustain = '0' +gen_drum_voice6_filter_env_release = '0.503937' +gen_drum_voice6_filter_env_depth = '0.5' +gen_drum_voice6_filter_env_reset = '0' +gen_drum_voice6_stereo_spread = '0.5' +gen_drum_voice6_amp_mode = '1' +gen_drum_voice6_amp_env_attack = '0' +gen_drum_voice6_amp_env_hold = '0.062992126' +gen_drum_voice6_amp_env_decay = '0.18897638' +gen_drum_voice6_amp_env_sustain = '1' +gen_drum_voice6_amp_env_release = '0.2519685' +gen_drum_voice6_amp_env_reset = '1' +gen_drum_voice6_volume = '0.7559055' +gen_drum_voice6_pan = '0.5' +gen_drum_voice6_lfo1_speed = '0.625' +gen_drum_voice6_lfo1_multiplier = '2' +gen_drum_voice6_lfo1_fade = '0.5' +gen_drum_voice6_lfo1_wave = 'Triangle' +gen_drum_voice6_lfo1_start_phase = '0' +gen_drum_voice6_lfo1_smoothing = '0' +gen_drum_voice6_lfo1_trig_mode = 'Free' +gen_drum_voice6_lfo1_destination = '26' +gen_drum_voice6_lfo1_depth = '0.712539' +gen_drum_voice6_lfo2_speed = '0.75' +gen_drum_voice6_lfo2_multiplier = '3' +gen_drum_voice6_lfo2_fade = '0.5' +gen_drum_voice6_lfo2_wave = 'Triangle' +gen_drum_voice6_lfo2_start_phase = '0' +gen_drum_voice6_lfo2_smoothing = '0' +gen_drum_voice6_lfo2_trig_mode = 'Free' +gen_drum_voice6_lfo2_destination = '23' +gen_drum_voice6_lfo2_depth = '0.54359376' +gen_drum_voice6_mod_env_delay = '0' +gen_drum_voice6_mod_env_attack = '0' +gen_drum_voice6_mod_env_decay = '0.503937' +gen_drum_voice6_mod_env_sustain = '0' +gen_drum_voice6_mod_env_release = '0.503937' +gen_drum_voice6_mod_env_reset = '0' +gen_drum_voice6_mod_env_destination = '0' +gen_drum_voice6_mod_env_depth = '0.5' +gen_drum_voice7_tune = '0.5' +gen_drum_voice7_play_mode = '1' +gen_drum_voice7_sample_start = '0' +gen_drum_voice7_play_length = '1' +gen_drum_voice7_loop_point = '-0.00008333333' +gen_drum_voice7_overdrive = '0.14173228' +gen_drum_voice7_base = '0.6692913' +gen_drum_voice7_width = '1' +gen_drum_voice7_filter_type = '0' +gen_drum_voice7_filter_frequency = '1' +gen_drum_voice7_filter_resonance = '0' +gen_drum_voice7_filter_env_delay = '0' +gen_drum_voice7_filter_env_attack = '0' +gen_drum_voice7_filter_env_decay = '0.503937' +gen_drum_voice7_filter_env_sustain = '0' +gen_drum_voice7_filter_env_release = '0.503937' +gen_drum_voice7_filter_env_depth = '0.5' +gen_drum_voice7_filter_env_reset = '0' +gen_drum_voice7_stereo_spread = '0.5' +gen_drum_voice7_amp_mode = '1' +gen_drum_voice7_amp_env_attack = '0' +gen_drum_voice7_amp_env_hold = '0' +gen_drum_voice7_amp_env_decay = '1' +gen_drum_voice7_amp_env_sustain = '1' +gen_drum_voice7_amp_env_release = '0.2519685' +gen_drum_voice7_amp_env_reset = '1' +gen_drum_voice7_volume = '0.7480315' +gen_drum_voice7_pan = '0.359375' +gen_drum_voice7_lfo1_speed = '0.75' +gen_drum_voice7_lfo1_multiplier = '1' +gen_drum_voice7_lfo1_fade = '0.5' +gen_drum_voice7_lfo1_wave = 'Sine' +gen_drum_voice7_lfo1_start_phase = '0' +gen_drum_voice7_lfo1_smoothing = '0' +gen_drum_voice7_lfo1_trig_mode = 'Free' +gen_drum_voice7_lfo1_destination = '0' +gen_drum_voice7_lfo1_depth = '0.5' +gen_drum_voice7_lfo2_speed = '0.75' +gen_drum_voice7_lfo2_multiplier = '3' +gen_drum_voice7_lfo2_fade = '0.5' +gen_drum_voice7_lfo2_wave = 'Triangle' +gen_drum_voice7_lfo2_start_phase = '0' +gen_drum_voice7_lfo2_smoothing = '0' +gen_drum_voice7_lfo2_trig_mode = 'Free' +gen_drum_voice7_lfo2_destination = '0' +gen_drum_voice7_lfo2_depth = '0.5' +gen_drum_voice7_mod_env_delay = '0' +gen_drum_voice7_mod_env_attack = '0' +gen_drum_voice7_mod_env_decay = '0.503937' +gen_drum_voice7_mod_env_sustain = '0' +gen_drum_voice7_mod_env_release = '0.503937' +gen_drum_voice7_mod_env_reset = '0' +gen_drum_voice7_mod_env_destination = '0' +gen_drum_voice7_mod_env_depth = '0.5' + diff --git a/src/main/resources/de/mossgrabers/convertwithmoss/templates/tonverk/multi-template.tvpst b/src/main/resources/de/mossgrabers/convertwithmoss/templates/tonverk/multi-template.tvpst new file mode 100644 index 00000000..cea484f0 --- /dev/null +++ b/src/main/resources/de/mossgrabers/convertwithmoss/templates/tonverk/multi-template.tvpst @@ -0,0 +1,146 @@ +version = 2 +category = 'KEYS' +tags = [ + 'NOISY', + 'PAD', + 'VINTAGE', +] + +[parameters] +arp_enabled = '0' +arp_speed = '13' +arp_note_length = '14' +arp_mode = '0' +arp_range = '1' +lfo1_speed = '32' +lfo1_multiplier = '1' +lfo1_destination = '' +lfo1_depth = '0' +lfo1_waveform = 'Sine' +lfo1_start_phase = '0' +lfo1_trig_mode = 'Free' +lfo1_smoothing = '0' +lfo2_speed = '32' +lfo2_multiplier = '3' +lfo2_destination = '' +lfo2_depth = '0' +lfo2_waveform = 'Triangle' +lfo2_start_phase = '0' +lfo2_trig_mode = 'Free' +lfo2_fade = '0' +lfo2_smoothing = '0' +pitchbend_value = '0' +pitchbend_mod1_destination = '' +pitchbend_mod2_destination = '' +pitchbend_mod3_destination = '' +pitchbend_mod4_destination = '' +pitchbend_mod1_depth = '0' +pitchbend_mod2_depth = '0' +pitchbend_mod3_depth = '0' +pitchbend_mod4_depth = '0' +aftertouch_value = '0' +aftertouch_mod1_destination = '' +aftertouch_mod2_destination = '' +aftertouch_mod3_destination = '' +aftertouch_mod4_destination = '' +aftertouch_mod1_depth = '0' +aftertouch_mod2_depth = '0' +aftertouch_mod3_depth = '0' +aftertouch_mod4_depth = '0' +modwheel_value = '0' +modwheel_mod1_destination = '' +modwheel_mod2_destination = '' +modwheel_mod3_destination = '' +modwheel_mod4_destination = '' +modwheel_mod1_depth = '0' +modwheel_mod2_depth = '0' +modwheel_mod3_depth = '0' +modwheel_mod4_depth = '0' +breath_control_value = '0' +breath_control_mod1_destination = '' +breath_control_mod2_destination = '' +breath_control_mod3_destination = '' +breath_control_mod4_destination = '' +breath_control_mod1_depth = '0' +breath_control_mod2_depth = '0' +breath_control_mod3_depth = '0' +breath_control_mod4_depth = '0' +fx1_machine = '0' +fx1_dirtshaper_drive = '0.26771653' +fx1_dirtshaper_rectify = '0.17322835' +fx1_dirtshaper_hpf = '0.70866144' +fx1_dirtshaper_lpf = '0.9448819' +fx1_dirtshaper_xnoise = '0.047244094' +fx1_dirtshaper_noise_freq = '0.11811024' +fx1_dirtshaper_noise_reso = '0.03937008' +fx1_dirtshaper_mix = '0.29999998' +fx2_machine = '0' +fx2_saturator_delay_time = '0.08629921' +fx2_saturator_delay_mode = '1' +fx2_saturator_delay_width = '0.8203125' +fx2_saturator_delay_feedback = '0.3181818' +fx2_saturator_delay_hp = '0.21259843' +fx2_saturator_delay_lp = '0.62992126' +fx2_saturator_delay_mix = '0.19' +midi_machine = '0' +gen_machine = '1' +gen_multi_poly_mode = '0' +gen_multi_note_priority = '0' +gen_multi_reuse_voices = '0' +gen_multi_octave = '0' +gen_multi_velocity_curve = '3' +gen_multi_tune = '0.5000833' +gen_multi_vibrato_depth = '0.03937008' +gen_multi_vibrato_speed = '0.19685039' +gen_multi_vibrato_fade = '0.5' +gen_multi_overdrive = '0.23622048' +gen_multi_base = '0' +gen_multi_width = '1' +gen_multi_filter_type = '0' +gen_multi_filter_frequency = '0.20023622' +gen_multi_filter_resonance = '0.031496063' +gen_multi_filter_env_delay = '0' +gen_multi_filter_env_attack = '0.41732284' +gen_multi_filter_env_decay = '0.6535433' +gen_multi_filter_env_sustain = '0.70866144' +gen_multi_filter_env_release = '0.6535433' +gen_multi_filter_env_depth = '0.734375' +gen_multi_filter_env_reset = '0' +gen_multi_filter_stereo_spread = '0.5' +gen_multi_filter_key_tracking = '0.51' +gen_multi_amp_mode = '2' +gen_multi_amp_env_attack = '0.4015748' +gen_multi_amp_env_hold = '0' +gen_multi_amp_env_decay = '0.503937' +gen_multi_amp_env_sustain = '1' +gen_multi_amp_env_release = '0.5826772' +gen_multi_amp_env_reset = '1' +gen_multi_volume = '0.6692913' +gen_multi_pan = '0.5' +gen_multi_lfo1_speed = '0.75' +gen_multi_lfo1_multiplier = '1' +gen_multi_lfo1_fade = '0.5' +gen_multi_lfo1_wave = 'Sine' +gen_multi_lfo1_start_phase = '0' +gen_multi_lfo1_smoothing = '0' +gen_multi_lfo1_trig_mode = 'Free' +gen_multi_lfo1_destination = '0' +gen_multi_lfo1_depth = '0.5' +gen_multi_lfo2_speed = '0.7710937' +gen_multi_lfo2_multiplier = '2' +gen_multi_lfo2_fade = '0.5' +gen_multi_lfo2_wave = 'Sine' +gen_multi_lfo2_start_phase = '0' +gen_multi_lfo2_smoothing = '0' +gen_multi_lfo2_trig_mode = 'Free' +gen_multi_lfo2_destination = '8' +gen_multi_lfo2_depth = '0.5184375' +gen_multi_mod_env_delay = '0' +gen_multi_mod_env_attack = '0' +gen_multi_mod_env_decay = '0.503937' +gen_multi_mod_env_sustain = '0' +gen_multi_mod_env_release = '0.503937' +gen_multi_mod_env_reset = '0' +gen_multi_mod_env_destination = '0' +gen_multi_mod_env_depth = '0.5' +