From 8b2d5ca3c36ead415f385700b3530743c6b6b063 Mon Sep 17 00:00:00 2001 From: Douglas Carmichael Date: Wed, 15 Jul 2026 15:26:35 -0400 Subject: [PATCH 1/4] Add support for the Kurzweil K2000/K2500/K2600 format (KRZ, K25, K26) Add a detector and creator for Kurzweil K2x00 object files. A file is a bank of numbered objects: programs, keymaps and samples (which may hold several recordings for multiple root keys and stereo pairs). The layout was derived from the source code of the GPL tool KurzFiler by Marc Halbruegge (see documentation/design/KURZWEIL_FORMAT.md). Reading converts each program into one multi-sample: the layers reference keymaps from which the key ranges, the 8 dynamic velocity levels, root keys, per-entry tunings, loops and the 16-bit sample data are read. Compacted keymaps (a shared sample ID for all entries) and all entry layouts (the method bits) are supported. Keymaps and samples which are not referenced by any program become multi-samples of their own. Zones which map samples from the device ROM cannot be converted and are skipped with a note. A zone which appears identically on several adjacent velocity levels is merged back into one zone. Writing creates a .krz file which uses only K2000 features and therefore loads on all three device families. Each multi-sample becomes a program with one layer and a keymap: one sample object is written per zone (16-bit, the sample rate is kept), the velocity layers are distributed onto the 8 dynamic levels (levels with the same zones share one entry table), tuning, partial or disabled key tracking and the root key are encoded into the per-entry tunings and the data after the loop end is cut off since the device plays a loop until the sample end. If any zone is stereo, all samples of the program are written as stereo left/right header pairs (mono zones are duplicated). Several multi-samples can be written into one file as a library; when the object IDs (200-999 per type) do not suffice, multiple files are created. Verified against the KurzFiler test corpus (including real-world .k26 banks): all files read; round-tripped files re-load byte-identical sample data, mappings and loops in the reference implementation. --- documentation/CHANGELOG.md | 1 + documentation/README-FORMATS.md | 8 + documentation/design/KURZWEIL_FORMAT.md | 164 +++++++ .../core/ConverterBackend.java | 4 + .../format/kurzweil/KurzweilCreator.java | 453 ++++++++++++++++++ .../format/kurzweil/KurzweilDetector.java | 408 ++++++++++++++++ .../format/kurzweil/KurzweilFile.java | 232 +++++++++ .../format/kurzweil/KurzweilKeymap.java | 321 +++++++++++++ .../format/kurzweil/KurzweilKeymapEntry.java | 132 +++++ .../format/kurzweil/KurzweilObjectID.java | 74 +++ .../format/kurzweil/KurzweilProgram.java | 332 +++++++++++++ .../format/kurzweil/KurzweilSample.java | 227 +++++++++ .../format/kurzweil/KurzweilSampleHeader.java | 338 +++++++++++++ src/main/resources/Strings.properties | 11 + 14 files changed, 2705 insertions(+) create mode 100644 documentation/design/KURZWEIL_FORMAT.md create mode 100644 src/main/java/de/mossgrabers/convertwithmoss/format/kurzweil/KurzweilCreator.java create mode 100644 src/main/java/de/mossgrabers/convertwithmoss/format/kurzweil/KurzweilDetector.java create mode 100644 src/main/java/de/mossgrabers/convertwithmoss/format/kurzweil/KurzweilFile.java create mode 100644 src/main/java/de/mossgrabers/convertwithmoss/format/kurzweil/KurzweilKeymap.java create mode 100644 src/main/java/de/mossgrabers/convertwithmoss/format/kurzweil/KurzweilKeymapEntry.java create mode 100644 src/main/java/de/mossgrabers/convertwithmoss/format/kurzweil/KurzweilObjectID.java create mode 100644 src/main/java/de/mossgrabers/convertwithmoss/format/kurzweil/KurzweilProgram.java create mode 100644 src/main/java/de/mossgrabers/convertwithmoss/format/kurzweil/KurzweilSample.java create mode 100644 src/main/java/de/mossgrabers/convertwithmoss/format/kurzweil/KurzweilSampleHeader.java diff --git a/documentation/CHANGELOG.md b/documentation/CHANGELOG.md index aafbbb4a..27f91a8c 100644 --- a/documentation/CHANGELOG.md +++ b/documentation/CHANGELOG.md @@ -3,6 +3,7 @@ ## 19.0.1 (unreleased) * New: Improved logging if WAV file could not be written. +* New: Added support for the Kurzweil K2000/K2500/K2600 format (KRZ, K25, K26) (thanks to Douglas Carmichael). * Synthstrom Deluge (thanks to Douglas Carmichael) * New: Added an Output Type creator option (Synth/Kit, CLI DelugeOutputType) to write a drum kit instead of a synth (sound) preset. A kit writes one drum per note, consolidating velocity layers and round-robins to the loudest layer (a Deluge drum is a single sample). The type is chosen explicitly because a one-sample-per-note layout is not necessarily a kit (e.g. a per-note synth bass). * New: Added a "Consolidate kit" option (CLI DelugeConsolidateKit) which reduces a drum kit to one drum per type (kick, snare, hi-hat, ...) ordered by drum role following the factory TR-808 layout (kick on the lowest row), so a beat can be programmed without switching rows. The consolidated drums are labelled by their role for a clean read-out on the device. diff --git a/documentation/README-FORMATS.md b/documentation/README-FORMATS.md index 3cfcb483..bea2c6f1 100644 --- a/documentation/README-FORMATS.md +++ b/documentation/README-FORMATS.md @@ -403,6 +403,14 @@ Since the format is pretty simple all data stored in the file is available for t Since the format supports only one group of a multi-sample, multiple destination files are created for each group available in the source. If there is more than one group in the source the name of the created file has the velocity range of the group added. Using that information a multi-sample with up to 4 groups can be created as a Performance in the device. +## Kurzweil K2000/K2500/K2600 + +The Kurzweil K2000 (1991), K2500 and K2600 samplers/synthesizers share one object file format with the extensions *.krz*, *.k25* and *.k26*. A file is a bank of numbered objects: programs (presets), keymaps (the mapping of sample recordings across the keys and the 8 dynamic velocity levels) and samples, which may contain several recordings (multiple root keys, stereo pairs). The layout was derived from the source code of the GPL tool KurzFiler by Marc Halbruegge (see *documentation/design/KURZWEIL_FORMAT.md*). + +When reading, each program becomes one multi-sample: its layers reference keymaps from which the key ranges, velocity levels, root keys, tunings, loops and the 16-bit sample data are read. Keymaps and samples which are not referenced by any program are converted as multi-samples of their own. Many factory and commercial K-series files map samples from the device ROM which is not present in the file; such zones cannot be converted and are skipped with a note. + +When writing, a *.krz* file is created which uses only K2000 features and therefore loads on all three device families. Each multi-sample becomes a program with one layer and a keymap; the velocity layers of the source are quantized onto the 8 dynamic levels of the keymap. One sample object is written per zone (16-bit, the sample rate is kept). Since the device plays a loop until the end of the sample, the data after the loop end is cut off. If any zone is stereo, all samples of the program are written as stereo pairs. The keymap covers MIDI notes 12-127 (C0-G9 in Kurzweil terms), keys below are dropped. Several multi-samples can be written into one file as a library; if the object IDs (200-999 per type) do not suffice, multiple files are created. + ## Logic EXS24 The Logic EXS24 format is a proprietary sample format used by Logic Pro, a digital audio workstation. It is primarily used for storing and playback of sampled instruments and sounds within Logic Pro. The format allows for comprehensive mapping and editing of samples, as well as providing various modulation and performance options. diff --git a/documentation/design/KURZWEIL_FORMAT.md b/documentation/design/KURZWEIL_FORMAT.md new file mode 100644 index 00000000..b96ecbd0 --- /dev/null +++ b/documentation/design/KURZWEIL_FORMAT.md @@ -0,0 +1,164 @@ +# Kurzweil K2000/K2500/K2600 File Format (.KRZ / .K25 / .K26) + +Object files of the Kurzweil K2000, K2500 and K2600 samplers/synthesizers. All three devices share +one container format; the extension only signals the intended device family. A K2500 reads K2000 +files, a K2600 reads both. Program objects may contain device specific segments (K2500 triple +modular processing, KDFX effects, KB3 organ mode) which older devices ignore or reject. + +The layout was derived from the source code of the GPL tool *KurzFiler* by Marc Halbruegge +(https://sourceforge.net/projects/kurzfiler/), which reads and writes these files and whose output +loads on the hardware. All multi-byte values are **big-endian**. Sample positions are counted in +16-bit **words**, not bytes. + +## File layout + +``` +offset size +0 4 magic "PRAM" +4 4 int32 objectSize - file offset where the raw sample data region starts + (= end of the object region) +8 24 6 x int32, mostly 0; the 3rd one (offset 16) holds the OS version, + e.g. 353 for 3.53 (written by KurzFiler, value is not essential) +32 ... object blocks (see below) up to 'objectSize' +objectSize raw sample data region: 16-bit big-endian PCM words, referenced by the + sample headers via absolute word indices (byte pos = objectSize + 2 * index) +``` + +## Object blocks + +The object region is a sequence of blocks: + +``` +int32 blockSize negative: -(4 + paddedObjectLength); a value >= 0 terminates the list + (the terminator conventionally holds the file offset after itself, + i.e. 'objectSize', but readers only test for 'not negative') +object see below, zero-padded to a 4-byte boundary +``` + +The next block starts at `blockPos + 4 + (-blockSize) - 4 = blockPos - blockSize`. + +### Common object header + +``` +uint16 hash object type and ID: for types <= 42: (type << 10) | id, id = 0..1023 + (usable IDs on the device are 1..999) +uint16 size total object size in bytes counted from the start of 'hash' (before padding) +uint16 nameOfs offset from the start of this field to the object data +name ASCII, 0-terminated, padded to even length (nameOfs = 2 + name field length; + max 16 characters on the device) +data size - nameOfs - 4 bytes +``` + +Object types: 36 = program, 37 = keymap, 38 = sample. (Other types use an 8-bit type in the +hash: 111 = quick-access bank, 112 = song, 113 = effect; these are preserved verbatim but not +interpreted here.) + +## Sample object (type 38) + +``` +int16 baseID (KurzFiler writes 1) +int16 numHeaders number of sample headers - 1 +int16 headersOfs offset from this field to the first header (always 8) +uint8 flags 1 = stereo: the headers form left/right pairs (even index = left) +uint8 unused1 +int16 copyID +int16 unused2 +numHeaders+1 x sample header (32 bytes each): + uint8 rootKey MIDI note of the recorded pitch + uint8 flags 0x80 = loop OFF (inverted!), 0x40 = sample data present in this + file (otherwise the header references device ROM), KurzFiler + writes 0x70 for imported samples + int8 volumeAdjust (unit not confirmed, KurzFiler writes 0) + int8 altVolumeAdjust + int16 maxPitch highest playable transposition in cents: + ceil(1200*log2(96000e-9*samplePeriod)) + 100*rootKey - 1200 + int16 offsetToName + int32 sampleStart absolute word index into the sample data region + int32 altSampleStart alternative start (used when the Alt switch is on) + int32 loopStart absolute word index; the loop always ends at sampleEnd + int32 sampleEnd absolute word index, inclusive (length = end - start + 1) + int16 offsetToEnvelope byte offset from this field to the natural envelope + int16 altOffsetToEnvelope byte offset from this field to the alternative envelope + int32 samplePeriod sample period in nanoseconds (44100 Hz -> 22676) +natural envelope records, 12 bytes each (6 x int16); KurzFiler writes two default records +{-1, 1, 0, 0, -1600, 0} per sample object and points both envelope offsets of every header +at the first one +``` + +A sample object with several headers is a *multi-root* sample (one recording per root key). +For stereo samples every second header is the right channel of the preceding left one; a +keymap entry references the left header, the right one is implicit. + +Non-looped samples: KurzFiler writes the loop flag as ON with `loopStart == sampleEnd` +(a degenerate 1-word loop that holds the last sample value); native one-shot samples have +flag bit 0x80 set instead. Readers should treat `loopStart >= sampleEnd` as "no loop". + +## Keymap object (type 37) + +``` +int16 sampleID sample object ID used for all entries when the method has no + per-entry sample ID (bit 0x02 clear, a "compacted" keymap) +int16 method bit set describing the per-entry fields (in this order): + 0x10 tuning as int16 (cents), else 0x08 tuning as int8 + 0x04 volume adjust as int8 + 0x02 sample ID as int16 + 0x01 sub-sample number as uint8 +int16 basePitch pitch of entry 0 in cents (0 = MIDI note 12) +int16 centsPerEntry key range covered per entry in cents (normally 100 = 1 semitone) +int16 entriesPerVel number of entries per velocity level - 1 (normally 127) +int16 entrySize bytes per entry (must match the method bits) +int16 level[8] for each of the 8 dynamic levels ppp..fff: byte offset from this + level field to the entry table of the velocity level to use; + writing: (8 - levelIndex) * 2 + tableIndex * tableSize +entry tables numTables x (entriesPerVel+1) x entrySize +``` + +Entry fields (presence and order by the method bits): `tuning`, `volumeAdjust`, `sampleID`, +`subSampleNumber`. The sub-sample number is the 1-based index of the header inside the sample +object; 0 marks an unused entry. For stereo samples it references the left header (1, 3, 5, ...). + +Key mapping: entry `i` responds to MIDI note `12 + (basePitch + i * centsPerEntry) / 100`. +With the standard values (basePitch 0, 100 cents, 128 entries) entry `i` = MIDI note `i + 12` +covering C-1..G9 of the keymap range C0..G10 in Kurzweil terms. + +Playback pitch: `recordedPitch + (playedNote - rootKey) * 100 + tuning` cents, i.e. tuning 0 +gives normal chromatic tracking of the header's root; a drum map cancels the tracking with +`tuning = 100 * (rootKey - playedNote)`. + +The number of distinct entry tables is derived from the level offsets (adjacent equal values +share a table). The 8 dynamic levels map linearly onto MIDI velocity (level j covers +velocities j*16 .. j*16+15). + +## Program object (type 36) + +A sequence of segments, each a tag byte followed by a fixed-length data block. The object data +ends with a 0 word. Data length by tag: 8 (PGM) and 9 (LYR) = 15 bytes, 15 (FX) = 7 bytes, +otherwise by `tag & 0xF8`: 16/20 (ASR/LFO) = 7, 24 (FUN) = 3, 32 (ENC/ENV/IMP) = 15, +64 (CAL) = 31, 80 (output blocks) = 15, 104 (KDFX) = 7, 120 (KB3) = 31. + +Segments used by KurzFiler generated programs (all other bytes 0): + +* PGM (tag 8): `[0]` mode (2 = K2000, 3 = K2500, 4 = needs KDFX), `[1]` number of layers, + `[3]` 0x37 bend range, `[4]` 64 portamento +* per layer: + * LYR (tag 9): `[1]` 0x18, `[3]`/`[4]` low/high key, `[5]`/`[6]` 0/0x7F (velocity range), + `[8]` enable flags: 0x04 mono, 0x24 stereo + * ENC (tag 32): all 0 (envelope control, "not natural") + * ENV (tag 33): amplitude envelope, `[1]` = 100, `[7]` = 100 + * CAL (tag 64): `[0]` 0x7F, `[1]` keymap transpose, `[3]` 0x2B, `[7..8]` keymap object ID + (big-endian), `[11..12]` second keymap object ID (same as the first for generated + programs), `[29]` 1 + * tags 0x50/0x51/0x52: `[0]` = 62/60/60 (output/pan blocks) + * tag 0x53: `[0]` 1, `[2]` 0x70, `[13]` 4, `[14]` 0x90 for stereo panning, 0x00 for mono +``` + +K2500/K2600 only segments have `(tag & 96) == 96`; converting a program to K2000 strips them +and sets mode 2. + +## Not interpreted / unknown + +* The unit of the volume adjust fields (sample header and keymap entry) is not confirmed and + therefore ignored. +* The natural envelope records and the program ENV/ASR/LFO/FUN segment contents beyond the + values above are not decoded. +* Multi-floppy spanning files (.KR1/.K21 etc. part files) are not supported. diff --git a/src/main/java/de/mossgrabers/convertwithmoss/core/ConverterBackend.java b/src/main/java/de/mossgrabers/convertwithmoss/core/ConverterBackend.java index 8406261c..71a8c8d5 100644 --- a/src/main/java/de/mossgrabers/convertwithmoss/core/ConverterBackend.java +++ b/src/main/java/de/mossgrabers/convertwithmoss/core/ConverterBackend.java @@ -60,6 +60,8 @@ import de.mossgrabers.convertwithmoss.format.kmp.KMPDetector; import de.mossgrabers.convertwithmoss.format.korgmultisample.KorgmultisampleCreator; import de.mossgrabers.convertwithmoss.format.korgmultisample.KorgmultisampleDetector; +import de.mossgrabers.convertwithmoss.format.kurzweil.KurzweilCreator; +import de.mossgrabers.convertwithmoss.format.kurzweil.KurzweilDetector; import de.mossgrabers.convertwithmoss.format.music1010.bento.BentoCreator; import de.mossgrabers.convertwithmoss.format.music1010.bento.BentoDetector; import de.mossgrabers.convertwithmoss.format.music1010.blackbox.Music1010Creator; @@ -165,6 +167,7 @@ public ConverterBackend (final INotifier notifier) this.detectors.add (new IsoDetector (notifier)); this.detectors.add (new KMPDetector (notifier)); this.detectors.add (new KorgmultisampleDetector (notifier)); + this.detectors.add (new KurzweilDetector (notifier)); this.detectors.add (new EXS24Detector (notifier)); this.detectors.add (new KontaktDetector (notifier)); this.detectors.add (new MaschineDetector (notifier)); @@ -200,6 +203,7 @@ public ConverterBackend (final INotifier notifier) this.creators.add (new TonverkPresetCreator (notifier)); this.creators.add (new KMPCreator (notifier)); this.creators.add (new KorgmultisampleCreator (notifier)); + this.creators.add (new KurzweilCreator (notifier)); this.creators.add (new EXS24Creator (notifier)); this.creators.add (new KontaktCreator (notifier)); this.creators.add (new MaschineCreator (notifier)); diff --git a/src/main/java/de/mossgrabers/convertwithmoss/format/kurzweil/KurzweilCreator.java b/src/main/java/de/mossgrabers/convertwithmoss/format/kurzweil/KurzweilCreator.java new file mode 100644 index 00000000..0fc73cf5 --- /dev/null +++ b/src/main/java/de/mossgrabers/convertwithmoss/format/kurzweil/KurzweilCreator.java @@ -0,0 +1,453 @@ +// 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.kurzweil; + +import java.io.BufferedOutputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import de.mossgrabers.convertwithmoss.core.IMultisampleSource; +import de.mossgrabers.convertwithmoss.core.INotifier; +import de.mossgrabers.convertwithmoss.core.creator.AbstractCreator; +import de.mossgrabers.convertwithmoss.core.creator.DestinationAudioFormat; +import de.mossgrabers.convertwithmoss.core.model.IGroup; +import de.mossgrabers.convertwithmoss.core.model.ISampleLoop; +import de.mossgrabers.convertwithmoss.core.model.ISampleZone; +import de.mossgrabers.convertwithmoss.core.settings.EmptySettingsUI; +import de.mossgrabers.convertwithmoss.file.AudioFileUtils; +import de.mossgrabers.convertwithmoss.file.wav.WaveFile; + + +/** + * Creator for Kurzweil K2000/K2500/K2600 files. Writes a .krz file which uses only K2000 features + * and therefore loads on all three device families. Each multi-sample becomes a program with one + * layer, a keymap and one sample object per zone; the velocity layers are mapped onto the 8 + * dynamic levels of the keymap. + * + * @author Jürgen Moßgraber + */ +public class KurzweilCreator extends AbstractCreator +{ + private static final DestinationAudioFormat DESTINATION_FORMAT = new DestinationAudioFormat (new int [] + { + 16 + }, -1, false); + + private static final int MAX_NAME_LENGTH = 16; + + /** The number of object IDs available per object type (200-999). */ + private static final int NUM_IDS = KurzweilObjectID.LAST_ID - KurzweilObjectID.FIRST_ID + 1; + + + /** The audio data and mapping parameters of one zone prepared for writing. */ + private static class PreparedZone + { + ISampleZone zone; + byte [][] channelData; + int sampleRate; + int rootKey; + int loopStart; + boolean isLooped; + int sampleID; + } + + + /** + * Constructor. + * + * @param notifier The notifier + */ + public KurzweilCreator (final INotifier notifier) + { + super ("Kurzweil K2x00", "Kurzweil", notifier, EmptySettingsUI.INSTANCE); + } + + + /** {@inheritDoc} */ + @Override + public boolean supportsPresetLibraries () + { + return true; + } + + + /** {@inheritDoc} */ + @Override + public void createPreset (final File destinationFolder, final IMultisampleSource multisampleSource) throws IOException + { + final KurzweilFile kurzweilFile = new KurzweilFile (); + this.addMultisample (kurzweilFile, multisampleSource, new HashSet<> ()); + this.writeFile (destinationFolder, multisampleSource.getName (), kurzweilFile); + } + + + /** {@inheritDoc} */ + @Override + public void createPresetLibrary (final File destinationFolder, final List multisampleSources, final String libraryName) throws IOException + { + final List parts = new ArrayList<> (); + KurzweilFile kurzweilFile = new KurzweilFile (); + parts.add (kurzweilFile); + Set usedNames = new HashSet<> (); + + for (final IMultisampleSource multisampleSource: multisampleSources) + { + if (this.isCancelled ()) + return; + + // Start a new file when the object IDs of the current one are used up + if (!kurzweilFile.getPrograms ().isEmpty () && !fitsIntoFile (kurzweilFile, multisampleSource)) + { + kurzweilFile = new KurzweilFile (); + parts.add (kurzweilFile); + usedNames = new HashSet<> (); + } + this.addMultisample (kurzweilFile, multisampleSource, usedNames); + } + + if (parts.size () > 1) + this.notifier.log ("IDS_KURZWEIL_LIBRARY_SPLIT", Integer.toString (multisampleSources.size ()), Integer.toString (parts.size ())); + for (int i = 0; i < parts.size (); i++) + this.writeFile (destinationFolder, parts.size () > 1 ? libraryName + " " + (i + 1) : libraryName, parts.get (i)); + } + + + private void writeFile (final File destinationFolder, final String name, final KurzweilFile kurzweilFile) throws IOException + { + if (kurzweilFile.getPrograms ().isEmpty ()) + return; + + final File outputFile = this.createUniqueFilename (destinationFolder, createSafeFilename (name), "krz"); + this.notifier.log ("IDS_NOTIFY_STORING", outputFile.getAbsolutePath ()); + try (final OutputStream out = new BufferedOutputStream (new FileOutputStream (outputFile))) + { + kurzweilFile.write (out); + } + this.notifier.log ("IDS_NOTIFY_PROGRESS_DONE"); + } + + + private static boolean fitsIntoFile (final KurzweilFile kurzweilFile, final IMultisampleSource multisampleSource) + { + int numZones = 0; + for (final IGroup group: multisampleSource.getNonEmptyGroups (true)) + numZones += group.getSampleZones ().size (); + return kurzweilFile.getPrograms ().size () < NUM_IDS && kurzweilFile.getKeymaps ().size () < NUM_IDS && kurzweilFile.getSamples ().size () + numZones <= NUM_IDS; + } + + + /** + * Add a multi-sample as a program with a keymap and one sample object per zone to the file. + * + * @param kurzweilFile The file to add the objects to + * @param multisampleSource The multi-sample to add + * @param usedNames All object names used in the file so far to create unique ones + * @throws IOException Could not convert the sample data + */ + private void addMultisample (final KurzweilFile kurzweilFile, final IMultisampleSource multisampleSource, final Set usedNames) throws IOException + { + final String name = multisampleSource.getName (); + + // Convert all zones first to know if the program needs to be stereo + final List preparedZones = new ArrayList<> (); + for (final IGroup group: multisampleSource.getNonEmptyGroups (true)) + for (final ISampleZone zone: group.getSampleZones ()) + { + final PreparedZone preparedZone = this.prepareZone (zone); + if (preparedZone != null) + preparedZones.add (preparedZone); + } + if (preparedZones.isEmpty ()) + { + this.notifier.logError ("IDS_KURZWEIL_NO_ZONES", name); + return; + } + boolean isStereo = false; + for (final PreparedZone preparedZone: preparedZones) + isStereo |= preparedZone.channelData.length == 2; + + // Create one sample object per zone + for (final PreparedZone preparedZone: preparedZones) + { + final int sampleID = KurzweilObjectID.FIRST_ID + kurzweilFile.getSamples ().size (); + if (sampleID > KurzweilObjectID.LAST_ID) + { + this.notifier.logError ("IDS_KURZWEIL_TOO_MANY_OBJECTS", preparedZone.zone.getName ()); + break; + } + preparedZone.sampleID = sampleID; + kurzweilFile.getSamples ().put (Integer.valueOf (sampleID), createSample (sampleID, createUniqueName (preparedZone.zone.getName (), usedNames), preparedZone, isStereo)); + } + + // Collect for each of the 8 dynamic levels the zones whose velocity range intersects its + // band, zones with the largest overlap first + final List> zonesOfLevel = new ArrayList<> (); + for (int level = 0; level < KurzweilKeymap.NUM_LEVELS; level++) + { + final int bandLow = level * 16; + final int bandHigh = bandLow + 15; + final List levelZones = new ArrayList<> (); + for (final PreparedZone preparedZone: preparedZones) + { + if (preparedZone.sampleID == 0) + continue; + final int velocityLow = Math.clamp (preparedZone.zone.getVelocityLow (), 0, 127); + final int velocityHigh = Math.clamp (preparedZone.zone.getVelocityHigh (), velocityLow, 127); + if (velocityLow <= bandHigh && velocityHigh >= bandLow) + levelZones.add (preparedZone); + } + levelZones.sort ((z1, z2) -> Integer.compare (calcBandOverlap (z2.zone, bandLow, bandHigh), calcBandOverlap (z1.zone, bandLow, bandHigh))); + zonesOfLevel.add (levelZones); + } + + // Create the keymap with one entry table per distinct zone set; levels with identical + // zones share a table + final int keymapID = KurzweilObjectID.FIRST_ID + kurzweilFile.getKeymaps ().size (); + final KurzweilKeymap keymap = new KurzweilKeymap (keymapID, createUniqueName (name, usedNames)); + final Map, Integer> tableOfZones = new LinkedHashMap<> (); + int numConflicts = 0; + int numOutOfRange = 0; + for (int level = 0; level < KurzweilKeymap.NUM_LEVELS; level++) + { + final List levelZones = zonesOfLevel.get (level); + Integer tableIndex = tableOfZones.get (levelZones); + if (tableIndex == null) + { + final KurzweilKeymapEntry [] entries = new KurzweilKeymapEntry [KurzweilKeymap.NUM_ENTRIES]; + for (int i = 0; i < entries.length; i++) + entries[i] = new KurzweilKeymapEntry (); + for (final PreparedZone preparedZone: levelZones) + { + numConflicts += fillEntries (entries, preparedZone); + numOutOfRange += Math.max (0, KurzweilKeymap.BASE_NOTE - preparedZone.zone.getKeyLow ()); + } + tableIndex = Integer.valueOf (keymap.addEntryTable (entries)); + tableOfZones.put (levelZones, tableIndex); + } + keymap.setTableIndexOfLevel (level, tableIndex.intValue ()); + } + kurzweilFile.getKeymaps ().put (Integer.valueOf (keymapID), keymap); + + if (numConflicts > 0) + this.notifier.logError ("IDS_KURZWEIL_OVERLAPPING_ZONES", Integer.toString (numConflicts), name); + if (numOutOfRange > 0) + this.notifier.logError ("IDS_KURZWEIL_KEYS_OUT_OF_RANGE", name); + + // Create the program with a single layer which plays the keymap + final int programID = KurzweilObjectID.FIRST_ID + kurzweilFile.getPrograms ().size (); + final KurzweilProgram program = new KurzweilProgram (programID, shortenName (name)); + program.addProgramBlock (); + program.addLayer (keymapID, isStereo); + kurzweilFile.getPrograms ().add (program); + } + + + private static int calcBandOverlap (final ISampleZone zone, final int bandLow, final int bandHigh) + { + final int velocityLow = Math.clamp (zone.getVelocityLow (), 0, 127); + final int velocityHigh = Math.clamp (zone.getVelocityHigh (), velocityLow, 127); + return Math.min (velocityHigh, bandHigh) - Math.max (velocityLow, bandLow); + } + + + /** + * Fill the keymap entries covered by the key range of the zone. + * + * @param entries The entries to fill + * @param preparedZone The prepared zone + * @return The number of entries which were already used by another zone + */ + private static int fillEntries (final KurzweilKeymapEntry [] entries, final PreparedZone preparedZone) + { + int numConflicts = 0; + final ISampleZone zone = preparedZone.zone; + final double keyTracking = zone.getKeyTracking (); + final int tuningCents = (int) Math.round (zone.getTuning () * 100); + + final int lowKey = Math.max (zone.getKeyLow (), KurzweilKeymap.BASE_NOTE); + final int highKey = Math.min (zone.getKeyHigh (), KurzweilKeymap.BASE_NOTE + KurzweilKeymap.NUM_ENTRIES - 1); + for (int note = lowKey; note <= highKey; note++) + { + final KurzweilKeymapEntry entry = entries[note - KurzweilKeymap.BASE_NOTE]; + if (entry.isUsed ()) + { + numConflicts++; + continue; + } + entry.setSampleID (preparedZone.sampleID); + entry.setSubSampleNumber (1); + // The device tracks the keyboard chromatically relative to the root key; the entry + // tuning holds the offset from that plus partial or disabled key tracking + entry.setTuning (Math.clamp (Math.round ((keyTracking - 1) * (note - preparedZone.rootKey) * 100) + tuningCents, Short.MIN_VALUE, Short.MAX_VALUE)); + } + return numConflicts; + } + + + /** + * Convert the audio data of a zone to 16-bit PCM and apply the start, stop and loop range. + * Since the device plays the loop until the end of the sample, the data is cut after the loop + * end. + * + * @param zone The zone to prepare + * @return The prepared zone or null if the audio format is not supported + * @throws IOException Could not convert the sample data + */ + private PreparedZone prepareZone (final ISampleZone zone) throws IOException + { + if (zone.getSampleData () == null) + { + this.notifier.logError (IDS_NOTIFY_ERR_MISSING_SAMPLE_DATA, zone.getName (), zone.getName ()); + return null; + } + + final WaveFile waveFile = AudioFileUtils.convertToWav (zone.getSampleData (), DESTINATION_FORMAT); + final int numChannels = waveFile.getFormatChunk ().getNumberOfChannels (); + if (numChannels > 2) + { + this.notifier.logError ("IDS_NOTIFY_ERR_MONO", Integer.toString (numChannels), zone.getName ()); + return null; + } + + final byte [] pcmData = waveFile.getDataChunk ().getData (); + final int numFrames = pcmData.length / (2 * numChannels); + if (numFrames == 0) + { + this.notifier.logError (IDS_NOTIFY_ERR_MISSING_SAMPLE_DATA, zone.getName (), zone.getName ()); + return null; + } + final int start = Math.clamp (zone.getStart (), 0, Math.max (0, numFrames - 1)); + int endInclusive = (zone.getStop () > 0 ? Math.min (zone.getStop (), numFrames) : numFrames) - 1; + + final PreparedZone preparedZone = new PreparedZone (); + preparedZone.zone = zone; + preparedZone.sampleRate = waveFile.getFormatChunk ().getSampleRate (); + preparedZone.rootKey = Math.clamp (zone.getKeyRoot () < 0 ? zone.getKeyLow () : zone.getKeyRoot (), 0, 127); + + final List loops = zone.getLoops (); + if (!loops.isEmpty ()) + { + final ISampleLoop loop = loops.get (0); + final int loopEnd = Math.min (loop.getEnd (), numFrames - 1); + if (loopEnd > start && loop.getStart () < loopEnd) + { + endInclusive = loopEnd; + preparedZone.isLooped = true; + preparedZone.loopStart = Math.clamp (loop.getStart () - start, 0, loopEnd - start); + } + } + if (endInclusive < start) + endInclusive = start; + + preparedZone.channelData = new byte [numChannels] []; + for (int channel = 0; channel < numChannels; channel++) + preparedZone.channelData[channel] = extractChannelBigEndian (pcmData, numChannels, channel, start, endInclusive); + return preparedZone; + } + + + /** + * Create a sample object for a prepared zone. In a stereo program all sample objects are + * stereo; the data of mono zones is duplicated to both channels. + * + * @param sampleID The object ID for the sample + * @param name The name of the sample object + * @param preparedZone The prepared zone + * @param isStereo True if the program is stereo + * @return The sample object + */ + private static KurzweilSample createSample (final int sampleID, final String name, final PreparedZone preparedZone, final boolean isStereo) + { + final KurzweilSample sample = new KurzweilSample (sampleID, name); + sample.setStereo (isStereo); + final int numChannels = isStereo ? 2 : 1; + for (int channel = 0; channel < numChannels; channel++) + { + final KurzweilSampleHeader header = new KurzweilSampleHeader (); + header.setSampleData (preparedZone.channelData[Math.min (channel, preparedZone.channelData.length - 1)]); + header.setSampleRate (preparedZone.sampleRate); + header.setRootKey (preparedZone.rootKey); + if (preparedZone.isLooped) + header.setLoopStart (preparedZone.loopStart); + sample.addHeader (header); + } + return sample; + } + + + /** + * Extract one channel from interleaved little-endian 16-bit PCM data as big-endian data. + * + * @param pcmData The interleaved little-endian PCM data + * @param numChannels The number of channels in the data + * @param channel The channel to extract + * @param startFrame The first frame to extract + * @param endFrameInclusive The last frame to extract + * @return The big-endian channel data + */ + private static byte [] extractChannelBigEndian (final byte [] pcmData, final int numChannels, final int channel, final int startFrame, final int endFrameInclusive) + { + final int numFrames = endFrameInclusive - startFrame + 1; + final byte [] channelData = new byte [numFrames * 2]; + for (int i = 0; i < numFrames; i++) + { + final int src = ((startFrame + i) * numChannels + channel) * 2; + // Convert from little-endian to big-endian + channelData[i * 2] = pcmData[src + 1]; + channelData[i * 2 + 1] = pcmData[src]; + } + return channelData; + } + + + /** + * Shorten a name to the maximum object name length and replace all non-ASCII characters. + * + * @param name The name + * @return The shortened name + */ + private static String shortenName (final String name) + { + final StringBuilder shortened = new StringBuilder (); + for (int i = 0; i < name.length () && shortened.length () < MAX_NAME_LENGTH; i++) + { + final char c = name.charAt (i); + shortened.append (c >= 32 && c <= 126 ? c : '_'); + } + return shortened.toString ().trim (); + } + + + /** + * Create a unique object name with the maximum object name length. + * + * @param name The name + * @param usedNames The names used so far in the file + * @return The unique name + */ + private static String createUniqueName (final String name, final Set usedNames) + { + String base = shortenName (name); + if (base.isBlank ()) + base = "Unnamed"; + String uniqueName = base; + int counter = 1; + while (!usedNames.add (uniqueName)) + { + counter++; + final String suffix = Integer.toString (counter); + uniqueName = base.substring (0, Math.min (base.length (), MAX_NAME_LENGTH - suffix.length ())) + suffix; + } + return uniqueName; + } +} diff --git a/src/main/java/de/mossgrabers/convertwithmoss/format/kurzweil/KurzweilDetector.java b/src/main/java/de/mossgrabers/convertwithmoss/format/kurzweil/KurzweilDetector.java new file mode 100644 index 00000000..863df467 --- /dev/null +++ b/src/main/java/de/mossgrabers/convertwithmoss/format/kurzweil/KurzweilDetector.java @@ -0,0 +1,408 @@ +// 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.kurzweil; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import de.mossgrabers.convertwithmoss.core.IMultisampleSource; +import de.mossgrabers.convertwithmoss.core.INotifier; +import de.mossgrabers.convertwithmoss.core.detector.AbstractDetector; +import de.mossgrabers.convertwithmoss.core.model.IGroup; +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.LoopType; +import de.mossgrabers.convertwithmoss.core.model.implementation.DefaultAudioMetadata; +import de.mossgrabers.convertwithmoss.core.model.implementation.DefaultGroup; +import de.mossgrabers.convertwithmoss.core.model.implementation.DefaultSampleLoop; +import de.mossgrabers.convertwithmoss.core.model.implementation.DefaultSampleZone; +import de.mossgrabers.convertwithmoss.core.model.implementation.InMemorySampleData; +import de.mossgrabers.convertwithmoss.core.settings.MetadataSettingsUI; +import de.mossgrabers.tools.FileUtils; + + +/** + * Detects recursively Kurzweil K2000/K2500/K2600 files in folders. Files must end with + * .krz, .k25 or .k26. Each program in a file becomes one multi-sample; + * keymaps and samples which are not referenced by a program are added as multi-samples of their + * own. + * + * @author Jürgen Moßgraber + */ +public class KurzweilDetector extends AbstractDetector +{ + /** + * Constructor. + * + * @param notifier The notifier + */ + public KurzweilDetector (final INotifier notifier) + { + super ("Kurzweil K2x00", "Kurzweil", notifier, new MetadataSettingsUI ("Kurzweil"), ".krz", ".k25", ".k26"); + } + + + /** {@inheritDoc} */ + @Override + public List readPresetFile (final File sourceFile) + { + if (this.waitForDelivery ()) + return Collections.emptyList (); + + final KurzweilFile kurzweilFile; + try + { + kurzweilFile = new KurzweilFile (Files.readAllBytes (sourceFile.toPath ())); + } + catch (final IOException ex) + { + this.notifier.logError ("IDS_NOTIFY_ERR_LOAD_FILE", ex); + return Collections.emptyList (); + } + + final List sources = new ArrayList<> (); + final Map sampleDataCache = new HashMap<> (); + final Set usedKeymapIDs = new HashSet<> (); + final Set usedSampleIDs = new HashSet<> (); + final Set reportedRomSampleIDs = new HashSet<> (); + + // Each program becomes one multi-sample + for (final KurzweilProgram program: kurzweilFile.getPrograms ()) + { + final String name = program.getName ().isBlank () ? FileUtils.getNameWithoutType (sourceFile) : program.getName (); + this.notifier.log ("IDS_KURZWEIL_READING", "program", name); + + final List groups = new ArrayList<> (); + final Set reportedMissingKeymapIDs = new HashSet<> (); + for (final KurzweilProgram.Layer layer: program.getLayers ()) + { + final Integer keymapID = Integer.valueOf (layer.getKeymapID ()); + final KurzweilKeymap keymap = kurzweilFile.getKeymaps ().get (keymapID); + if (keymap == null) + { + if (reportedMissingKeymapIDs.add (keymapID)) + this.notifier.logError ("IDS_KURZWEIL_KEYMAP_MISSING", keymapID.toString (), name); + continue; + } + usedKeymapIDs.add (keymapID); + this.createGroupsFromKeymap (kurzweilFile, keymap, layer, groups, sampleDataCache, usedSampleIDs, reportedRomSampleIDs); + } + this.addSource (sources, sourceFile, name, groups); + } + + // Keymaps which are not referenced by a program become multi-samples of their own + for (final KurzweilKeymap keymap: kurzweilFile.getKeymaps ().values ()) + { + if (usedKeymapIDs.contains (Integer.valueOf (keymap.getId ()))) + continue; + final String name = keymap.getName ().isBlank () ? FileUtils.getNameWithoutType (sourceFile) : keymap.getName (); + this.notifier.log ("IDS_KURZWEIL_READING", "keymap", name); + final List groups = new ArrayList<> (); + this.createGroupsFromKeymap (kurzweilFile, keymap, new KurzweilProgram.Layer (), groups, sampleDataCache, usedSampleIDs, reportedRomSampleIDs); + this.addSource (sources, sourceFile, name, groups); + } + + // Samples which are not referenced by a keymap as well + for (final KurzweilSample sample: kurzweilFile.getSamples ().values ()) + { + if (usedSampleIDs.contains (Integer.valueOf (sample.getId ()))) + continue; + final String name = sample.getName ().isBlank () ? FileUtils.getNameWithoutType (sourceFile) : sample.getName (); + this.notifier.log ("IDS_KURZWEIL_READING", "sample", name); + this.addSource (sources, sourceFile, name, this.createGroupsFromSample (sample, sampleDataCache, reportedRomSampleIDs)); + } + + return sources; + } + + + private void addSource (final List sources, final File sourceFile, final String name, final List groups) + { + mergeAdjacentVelocityZones (groups); + + final List filledGroups = new ArrayList<> (); + for (final IGroup group: groups) + if (!group.getSampleZones ().isEmpty ()) + filledGroups.add (group); + + if (filledGroups.isEmpty ()) + this.notifier.logError ("IDS_KURZWEIL_NO_ZONES", name); + else + sources.add (this.createMultisampleSource (sourceFile, name, filledGroups)); + } + + + /** + * A zone which appears identically on several velocity levels (e.g. a full velocity pad + * combined with velocity split zones on other keys) is read as one zone per level. Merge + * such zones when their velocity ranges are adjacent. + * + * @param groups The groups with the zones to merge + */ + private static void mergeAdjacentVelocityZones (final List groups) + { + boolean merged; + do + { + merged = false; + for (int i = 0; i < groups.size (); i++) + for (final ISampleZone zone: groups.get (i).getSampleZones ()) + for (int j = i + 1; j < groups.size (); j++) + { + final ISampleZone other = findAdjacentZone (groups.get (j), zone); + if (other != null) + { + zone.setVelocityLow (Math.min (zone.getVelocityLow (), other.getVelocityLow ())); + zone.setVelocityHigh (Math.max (zone.getVelocityHigh (), other.getVelocityHigh ())); + groups.get (j).getSampleZones ().remove (other); + merged = true; + } + } + } while (merged); + } + + + private static ISampleZone findAdjacentZone (final IGroup group, final ISampleZone zone) + { + for (final ISampleZone other: group.getSampleZones ()) + if (other.getSampleData () == zone.getSampleData () && other.getKeyLow () == zone.getKeyLow () && other.getKeyHigh () == zone.getKeyHigh () && other.getKeyRoot () == zone.getKeyRoot () && other.getTuning () == zone.getTuning () && (other.getVelocityLow () == zone.getVelocityHigh () + 1 || other.getVelocityHigh () + 1 == zone.getVelocityLow ())) + return other; + return null; + } + + + /** + * Create the groups (one per velocity level) with their zones from a keymap. + * + * @param kurzweilFile The file which contains the referenced samples + * @param keymap The keymap to convert + * @param layer The program layer which references the keymap - limits the key range and + * transposes the zones + * @param groups Where to add the created groups + * @param sampleDataCache Cache of the already converted sample data + * @param usedSampleIDs All sample IDs referenced from keymaps are added to this set + * @param reportedRomSampleIDs The IDs of the ROM samples which were already reported + */ + private void createGroupsFromKeymap (final KurzweilFile kurzweilFile, final KurzweilKeymap keymap, final KurzweilProgram.Layer layer, final List groups, final Map sampleDataCache, final Set usedSampleIDs, final Set reportedRomSampleIDs) + { + final List entryTables = keymap.getEntryTables (); + for (int tableIndex = 0; tableIndex < entryTables.size (); tableIndex++) + { + // The velocity range of the table spans all dynamic levels which reference it + int lowLevel = -1; + int highLevel = -1; + for (int level = 0; level < KurzweilKeymap.NUM_LEVELS; level++) + if (keymap.getTableIndexOfLevel (level) == tableIndex) + { + if (lowLevel < 0) + lowLevel = level; + highLevel = level; + } + if (lowLevel < 0) + continue; + + String groupName = keymap.getName (); + if (entryTables.size () > 1) + groupName += " " + KurzweilKeymap.LEVEL_NAMES[lowLevel] + (highLevel > lowLevel ? "-" + KurzweilKeymap.LEVEL_NAMES[highLevel] : ""); + final IGroup group = new DefaultGroup (groupName); + + final int velocityLow = Math.max (1, lowLevel * 16); + final int velocityHigh = highLevel * 16 + 15; + + // Combine runs of identical entries into one zone + final KurzweilKeymapEntry [] entries = entryTables.get (tableIndex); + int index = 0; + while (index < entries.length) + { + if (!entries[index].isUsed ()) + { + index++; + continue; + } + int lastIndex = index; + while (lastIndex + 1 < entries.length && entries[lastIndex + 1].isIdentical (entries[index])) + lastIndex++; + + final ISampleZone zone = this.createZone (kurzweilFile, keymap, entries[index], index, lastIndex, layer, sampleDataCache, usedSampleIDs, reportedRomSampleIDs); + if (zone != null) + { + zone.setVelocityLow (velocityLow); + zone.setVelocityHigh (velocityHigh); + group.addSampleZone (zone); + } + + index = lastIndex + 1; + } + + groups.add (group); + } + } + + + /** + * Create a zone from a run of identical keymap entries. + * + * @param kurzweilFile The file which contains the referenced samples + * @param keymap The keymap + * @param entry The (first) entry of the run + * @param firstIndex The index of the first entry of the run + * @param lastIndex The index of the last entry of the run + * @param layer The program layer + * @param sampleDataCache Cache of the already converted sample data + * @param usedSampleIDs All sample IDs referenced from keymaps are added to this set + * @param reportedRomSampleIDs The IDs of the ROM samples which were already reported + * @return The zone or null if it cannot be created + */ + private ISampleZone createZone (final KurzweilFile kurzweilFile, final KurzweilKeymap keymap, final KurzweilKeymapEntry entry, final int firstIndex, final int lastIndex, final KurzweilProgram.Layer layer, final Map sampleDataCache, final Set usedSampleIDs, final Set reportedRomSampleIDs) + { + final Integer sampleID = Integer.valueOf (entry.getSampleID ()); + final KurzweilSample sample = kurzweilFile.getSamples ().get (sampleID); + if (sample == null) + { + this.notifier.logError ("IDS_KURZWEIL_SAMPLE_MISSING", sampleID.toString (), keymap.getName ()); + return null; + } + usedSampleIDs.add (sampleID); + + final int keyLow = Math.clamp (Math.max (keymap.getNoteOfEntry (firstIndex), layer.getLowKey ()), 0, 127); + final int keyHigh = Math.min (keymap.getNoteOfEntry (lastIndex), layer.getHighKey ()); + if (keyLow > keyHigh || keyHigh < 0 || keyLow > 127) + return null; + + // For stereo samples the entry references the left header of a left/right pair + final List headers = sample.getHeaders (); + final int headerIndex = sample.isStereo () ? entry.getSubSampleNumber () - 1 & ~1 : entry.getSubSampleNumber () - 1; + final KurzweilSampleHeader header = headerIndex >= 0 && headerIndex < headers.size () ? headers.get (headerIndex) : null; + final KurzweilSampleHeader rightHeader = sample.isStereo () && headerIndex + 1 < headers.size () ? headers.get (headerIndex + 1) : null; + if (header == null || sample.isStereo () && rightHeader == null) + { + this.notifier.logError ("IDS_KURZWEIL_BAD_SUB_SAMPLE", Integer.toString (entry.getSubSampleNumber ()), sample.getName ()); + return null; + } + if (!header.hasSampleData () || rightHeader != null && !rightHeader.hasSampleData ()) + { + if (reportedRomSampleIDs.add (sampleID)) + this.notifier.logError ("IDS_KURZWEIL_ROM_SAMPLE", sample.getName ()); + return null; + } + + final ISampleZone zone = new DefaultSampleZone (sample.getName (), Math.min (keyLow, keyHigh), keyHigh); + zone.setSampleData (sampleDataCache.computeIfAbsent (Long.valueOf ((long) sample.getId () << 16 | headerIndex), _ -> createSampleData (header, rightHeader))); + zone.setKeyRoot (header.getRootKey ()); + zone.setTuning ((entry.getTuning () + layer.getTranspose () * 100) / 100.0); + zone.setStart (0); + zone.setStop (header.getNumberOfFrames ()); + addLoop (zone, header); + return zone; + } + + + /** + * Create one group with one zone per sample header for a sample object which is not + * referenced by any keymap. The key ranges are spread around the root keys of the headers. + * + * @param sample The sample object + * @param sampleDataCache Cache of the already converted sample data + * @param reportedRomSampleIDs The IDs of the ROM samples which were already reported + * @return The created group + */ + private List createGroupsFromSample (final KurzweilSample sample, final Map sampleDataCache, final Set reportedRomSampleIDs) + { + final int headerStep = sample.isStereo () ? 2 : 1; + final List headers = sample.getHeaders (); + + // Collect the headers with sample data sorted by their root key + final List headerIndices = new ArrayList<> (); + for (int i = 0; i + headerStep - 1 < headers.size (); i += headerStep) + if (headers.get (i).hasSampleData () && (headerStep == 1 || headers.get (i + 1).hasSampleData ())) + headerIndices.add (Integer.valueOf (i)); + else if (reportedRomSampleIDs.add (Integer.valueOf (sample.getId ()))) + this.notifier.logError ("IDS_KURZWEIL_ROM_SAMPLE", sample.getName ()); + headerIndices.sort ((i1, i2) -> Integer.compare (headers.get (i1.intValue ()).getRootKey (), headers.get (i2.intValue ()).getRootKey ())); + + final IGroup group = new DefaultGroup (sample.getName ()); + for (int i = 0; i < headerIndices.size (); i++) + { + final int headerIndex = headerIndices.get (i).intValue (); + final KurzweilSampleHeader header = headers.get (headerIndex); + final KurzweilSampleHeader rightHeader = headerStep == 2 ? headers.get (headerIndex + 1) : null; + + // Split the key range in the middle between neighboring root keys + final int rootKey = Math.clamp (header.getRootKey (), 0, 127); + final int keyLow = i == 0 ? 0 : Math.min ((headers.get (headerIndices.get (i - 1).intValue ()).getRootKey () + rootKey) / 2 + 1, rootKey); + final int keyHigh = i == headerIndices.size () - 1 ? 127 : Math.max ((rootKey + headers.get (headerIndices.get (i + 1).intValue ()).getRootKey ()) / 2, rootKey); + + final ISampleZone zone = new DefaultSampleZone (sample.getName (), keyLow, keyHigh); + zone.setSampleData (sampleDataCache.computeIfAbsent (Long.valueOf ((long) sample.getId () << 16 | headerIndex), _ -> createSampleData (header, rightHeader))); + zone.setKeyRoot (rootKey); + zone.setStart (0); + zone.setStop (header.getNumberOfFrames ()); + addLoop (zone, header); + group.addSampleZone (zone); + } + + return Collections.singletonList (group); + } + + + private static void addLoop (final ISampleZone zone, final KurzweilSampleHeader header) + { + if (!header.isLooped ()) + return; + final ISampleLoop loop = new DefaultSampleLoop (); + loop.setType (LoopType.FORWARDS); + loop.setStart (header.getLoopStart ()); + loop.setEnd (header.getSampleEnd ()); + zone.getLoops ().add (loop); + } + + + /** + * Convert the 16-bit big-endian PCM data of a sample header (or a stereo pair of headers) + * into sample data for a zone. + * + * @param header The (left) sample header + * @param rightHeader The right channel header of a stereo pair or null for mono + * @return The sample data + */ + private static ISampleData createSampleData (final KurzweilSampleHeader header, final KurzweilSampleHeader rightHeader) + { + final byte [] leftData = header.getSampleData (); + final int numFrames = header.getNumberOfFrames (); + + if (rightHeader == null) + { + final byte [] pcmData = new byte [numFrames * 2]; + for (int i = 0; i < numFrames; i++) + { + // Convert from big-endian to little-endian + pcmData[i * 2] = leftData[i * 2 + 1]; + pcmData[i * 2 + 1] = leftData[i * 2]; + } + return new InMemorySampleData (new DefaultAudioMetadata (1, header.getSampleRate (), 16, numFrames), pcmData); + } + + final byte [] rightData = rightHeader.getSampleData (); + final int numStereoFrames = Math.min (numFrames, rightHeader.getNumberOfFrames ()); + final byte [] pcmData = new byte [numStereoFrames * 4]; + for (int i = 0; i < numStereoFrames; i++) + { + pcmData[i * 4] = leftData[i * 2 + 1]; + pcmData[i * 4 + 1] = leftData[i * 2]; + pcmData[i * 4 + 2] = rightData[i * 2 + 1]; + pcmData[i * 4 + 3] = rightData[i * 2]; + } + return new InMemorySampleData (new DefaultAudioMetadata (2, header.getSampleRate (), 16, numStereoFrames), pcmData); + } +} diff --git a/src/main/java/de/mossgrabers/convertwithmoss/format/kurzweil/KurzweilFile.java b/src/main/java/de/mossgrabers/convertwithmoss/format/kurzweil/KurzweilFile.java new file mode 100644 index 00000000..35c08f77 --- /dev/null +++ b/src/main/java/de/mossgrabers/convertwithmoss/format/kurzweil/KurzweilFile.java @@ -0,0 +1,232 @@ +// 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.kurzweil; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import de.mossgrabers.convertwithmoss.file.StreamUtils; + + +/** + * A Kurzweil K2000/K2500/K2600 object file (.krz, .k25, .k26). The file starts with a 32 byte + * header ('PRAM' magic) followed by the object blocks and the raw sample data region. Each object + * block is prefixed with its negative length; a non-negative value terminates the object list. + * All values are big-endian. + * + * The format was derived from the source code of the KurzFiler tool by Marc Halbruegge, see + * documentation/design/KURZWEIL_FORMAT.md for the details. + * + * @author Jürgen Moßgraber + */ +public class KurzweilFile +{ + private static final byte [] MAGIC = "PRAM".getBytes (); + + /** The OS version to write into the header (3.53, the value KurzFiler writes). */ + private static final int OS_VERSION = 353; + + private final Map samples = new LinkedHashMap<> (); + private final Map keymaps = new LinkedHashMap<> (); + private final List programs = new ArrayList<> (); + + + /** + * Constructor for a new empty file. + */ + public KurzweilFile () + { + // Intentionally empty + } + + + /** + * Constructor. Parses the file from the given data. + * + * @param fileData The content of the file + * @throws IOException Could not parse the file + */ + public KurzweilFile (final byte [] fileData) throws IOException + { + if (fileData.length < 32 || fileData[0] != MAGIC[0] || fileData[1] != MAGIC[1] || fileData[2] != MAGIC[2] || fileData[3] != MAGIC[3]) + throw new IOException ("Not a Kurzweil K2000/K2500/K2600 file."); + + final int sampleDataOffset = readSigned32 (fileData, 4); + + int position = 32; + while (position + 4 <= fileData.length) + { + final int blockSize = readSigned32 (fileData, position); + if (blockSize >= 0) + break; + final int objectLength = -blockSize - 4; + final int objectStart = position + 4; + if (objectLength < 6 || objectStart + objectLength > fileData.length) + throw new IOException ("Broken object block in Kurzweil file."); + + this.parseObject (fileData, objectStart, objectLength); + + position = objectStart + objectLength; + } + + for (final KurzweilSample sample: this.samples.values ()) + for (final KurzweilSampleHeader header: sample.getHeaders ()) + header.extractSampleData (fileData, sampleDataOffset); + } + + + private void parseObject (final byte [] fileData, final int objectStart, final int objectLength) throws IOException + { + final int hash = (fileData[objectStart] & 0xFF) << 8 | fileData[objectStart + 1] & 0xFF; + final int nameOffset = (fileData[objectStart + 4] & 0xFF) << 8 | fileData[objectStart + 5] & 0xFF; + final int dataStart = 4 + nameOffset; + if (dataStart > objectLength) + throw new IOException ("Broken object header in Kurzweil file."); + + final StringBuilder name = new StringBuilder (); + for (int i = objectStart + 6; i < objectStart + dataStart && fileData[i] != 0; i++) + name.append ((char) (fileData[i] & 0xFF)); + + final int id = KurzweilObjectID.getID (hash); + final ByteArrayInputStream in = new ByteArrayInputStream (fileData, objectStart + dataStart, objectLength - dataStart); + switch (KurzweilObjectID.getType (hash)) + { + case KurzweilObjectID.TYPE_SAMPLE: + this.samples.put (Integer.valueOf (id), new KurzweilSample (id, name.toString (), in)); + break; + case KurzweilObjectID.TYPE_KEYMAP: + this.keymaps.put (Integer.valueOf (id), new KurzweilKeymap (id, name.toString (), in)); + break; + case KurzweilObjectID.TYPE_PROGRAM: + this.programs.add (new KurzweilProgram (id, name.toString (), in)); + break; + default: + // All other object types (songs, effects, quick-access banks, ...) are not + // relevant for the conversion + break; + } + } + + + private static int readSigned32 (final byte [] data, final int offset) + { + return (data[offset] & 0xFF) << 24 | (data[offset + 1] & 0xFF) << 16 | (data[offset + 2] & 0xFF) << 8 | data[offset + 3] & 0xFF; + } + + + /** + * Write the file to the stream. The objects are written in hash order (programs, keymaps, + * samples) followed by the sample data of all sample objects in the same order. + * + * @param out The output stream to write to + * @throws IOException Could not write the file + */ + public void write (final OutputStream out) throws IOException + { + // The sample data of the objects is placed consecutively into the sample data region + final Map wordOffsets = new LinkedHashMap<> (); + int wordOffset = 0; + for (final KurzweilSample sample: this.samples.values ()) + { + wordOffsets.put (sample, Integer.valueOf (wordOffset)); + wordOffset += sample.getNumberOfWords (); + } + + final ByteArrayOutputStream objectRegion = new ByteArrayOutputStream (); + for (final KurzweilProgram program: this.programs) + writeObjectBlock (objectRegion, KurzweilObjectID.createHash (KurzweilObjectID.TYPE_PROGRAM, program.getId ()), program.getName (), program.createObjectData ()); + for (final KurzweilKeymap keymap: this.keymaps.values ()) + writeObjectBlock (objectRegion, KurzweilObjectID.createHash (KurzweilObjectID.TYPE_KEYMAP, keymap.getId ()), keymap.getName (), keymap.createObjectData ()); + for (final KurzweilSample sample: this.samples.values ()) + writeObjectBlock (objectRegion, KurzweilObjectID.createHash (KurzweilObjectID.TYPE_SAMPLE, sample.getId ()), sample.getName (), sample.createObjectData (wordOffsets.get (sample).intValue ())); + + // The header with the offset of the sample data region (header + objects + terminator) + out.write (MAGIC); + StreamUtils.writeSigned32 (out, 32 + objectRegion.size () + 4, true); + for (int i = 0; i < 6; i++) + StreamUtils.writeSigned32 (out, i == 2 ? OS_VERSION : 0, true); + + objectRegion.writeTo (out); + + // The terminating block size + StreamUtils.writeSigned32 (out, 0, true); + + for (final KurzweilSample sample: this.samples.values ()) + for (final KurzweilSampleHeader header: sample.getHeaders ()) + if (header.hasSampleData ()) + out.write (header.getSampleData ()); + } + + + private static void writeObjectBlock (final ByteArrayOutputStream out, final int hash, final String name, final byte [] objectData) throws IOException + { + final ByteArrayOutputStream object = new ByteArrayOutputStream (); + StreamUtils.writeUnsigned16 (object, hash, true); + // Placeholder for the object size + StreamUtils.writeUnsigned16 (object, 0, true); + final int nameLength = name.length (); + StreamUtils.writeUnsigned16 (object, nameLength % 2 == 0 ? nameLength + 4 : nameLength + 3, true); + for (int i = 0; i < nameLength; i++) + object.write (name.charAt (i)); + // 0-terminate the name and pad it to an even length + object.write (0); + if (nameLength % 2 == 0) + object.write (0); + object.write (objectData); + // Pad the object to an even length + if (object.size () % 2 == 1) + object.write (0); + + final byte [] objectBytes = object.toByteArray (); + objectBytes[2] = (byte) (objectBytes.length >>> 8 & 0xFF); + objectBytes[3] = (byte) (objectBytes.length & 0xFF); + + // The block: the negative length prefix, the object and padding to a 4-byte boundary + final int paddedLength = objectBytes.length + 3 & ~3; + StreamUtils.writeSigned32 (out, -(4 + paddedLength), true); + out.write (objectBytes); + for (int i = objectBytes.length; i < paddedLength; i++) + out.write (0); + } + + + /** + * Get all sample objects indexed by their ID. + * + * @return The sample objects + */ + public Map getSamples () + { + return this.samples; + } + + + /** + * Get all keymap objects indexed by their ID. + * + * @return The keymap objects + */ + public Map getKeymaps () + { + return this.keymaps; + } + + + /** + * Get all program objects. + * + * @return The program objects + */ + public List getPrograms () + { + return this.programs; + } +} diff --git a/src/main/java/de/mossgrabers/convertwithmoss/format/kurzweil/KurzweilKeymap.java b/src/main/java/de/mossgrabers/convertwithmoss/format/kurzweil/KurzweilKeymap.java new file mode 100644 index 00000000..f9c3bbef --- /dev/null +++ b/src/main/java/de/mossgrabers/convertwithmoss/format/kurzweil/KurzweilKeymap.java @@ -0,0 +1,321 @@ +// 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.kurzweil; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.List; + +import de.mossgrabers.convertwithmoss.file.StreamUtils; + + +/** + * A Kurzweil keymap object (type 37). Maps sample recordings across the key range: entry i + * responds to the MIDI note 12 + (basePitch + i * centsPerEntry) / 100. The 8 dynamic levels + * (ppp..fff, mapped linearly onto the MIDI velocity range) each reference one of the entry + * tables; several levels may share a table. + * + * The method bits define which fields are stored per entry: 0x10 tuning as 16-bit (else 0x08 + * tuning as 8-bit), 0x04 volume adjust, 0x02 sample ID, 0x01 sub-sample number. Without the + * per-entry sample ID bit all entries use the sample ID of the keymap ('compacted' keymap). + * + * @author Jürgen Moßgraber + */ +public class KurzweilKeymap +{ + /** The number of dynamic levels. */ + public static final int NUM_LEVELS = 8; + + /** The names of the 8 dynamic levels. */ + public static final String [] LEVEL_NAMES = + { + "ppp", + "pp", + "p", + "mp", + "mf", + "f", + "ff", + "fff" + }; + + private static final int METHOD_TUNING_16 = 0x10; + private static final int METHOD_TUNING_8 = 0x08; + private static final int METHOD_VOLUME = 0x04; + private static final int METHOD_SAMPLE_ID = 0x02; + private static final int METHOD_SUB_SAMPLE = 0x01; + + /** 16-bit tuning, sample ID and sub-sample number per entry (5 bytes). */ + public static final int METHOD_DEFAULT = METHOD_TUNING_16 | METHOD_SAMPLE_ID | METHOD_SUB_SAMPLE; + + /** The lowest MIDI note of a keymap with base pitch 0. */ + public static final int BASE_NOTE = 12; + + /** The number of entries of a standard keymap (1 per semitone). */ + public static final int NUM_ENTRIES = 128; + + private int id; + private String name; + private int sampleId; + private int method = METHOD_DEFAULT; + private int basePitch; + private int centsPerEntry = 100; + private int numEntries = NUM_ENTRIES; + + private final int [] levelTableIndices = new int [NUM_LEVELS]; + private final List entryTables = new ArrayList<> (); + + + /** + * Constructor for a new empty keymap. + * + * @param id The object ID + * @param name The name of the keymap, maximum 16 characters + */ + public KurzweilKeymap (final int id, final String name) + { + this.id = id; + this.name = name; + } + + + /** + * Constructor. Reads the object data (the part after the object name) from the stream. + * + * @param id The object ID + * @param name The name of the keymap + * @param in The input stream to read from + * @throws IOException Could not read the object + */ + public KurzweilKeymap (final int id, final String name, final InputStream in) throws IOException + { + this.id = id; + this.name = name; + + this.sampleId = StreamUtils.readSigned16 (in, true); + this.method = StreamUtils.readSigned16 (in, true); + this.basePitch = StreamUtils.readSigned16 (in, true); + this.centsPerEntry = StreamUtils.readSigned16 (in, true); + this.numEntries = StreamUtils.readSigned16 (in, true) + 1; + // The entry size - implied by the method bits + StreamUtils.readSigned16 (in, true); + + // The level offsets point from each level field to the entry table of that dynamic + // level. Normalizing them by the distance to the end of the level fields leaves for each + // level the offset of its table into the entry table area + final int [] levelOffsets = new int [NUM_LEVELS]; + int numTables = 1; + for (int i = 0; i < NUM_LEVELS; i++) + levelOffsets[i] = StreamUtils.readSigned16 (in, true) - (NUM_LEVELS - i) * 2; + for (int i = 1; i < NUM_LEVELS; i++) + if (levelOffsets[i] != levelOffsets[i - 1]) + numTables++; + + final int entrySize = getEntrySize (this.method); + if (entrySize == 0 || this.numEntries <= 0) + throw new IOException ("Invalid keymap method or entry count."); + + final int tableSize = this.numEntries * entrySize; + if (numTables * tableSize > in.available ()) + throw new IOException ("Broken keymap object in Kurzweil file."); + for (int table = 0; table < numTables; table++) + { + final KurzweilKeymapEntry [] entries = new KurzweilKeymapEntry [this.numEntries]; + for (int i = 0; i < this.numEntries; i++) + entries[i] = this.readEntry (in); + this.entryTables.add (entries); + + for (int level = 0; level < NUM_LEVELS; level++) + { + if (levelOffsets[level] == 0) + this.levelTableIndices[level] = table; + levelOffsets[level] -= tableSize; + } + } + } + + + private KurzweilKeymapEntry readEntry (final InputStream in) throws IOException + { + final KurzweilKeymapEntry entry = new KurzweilKeymapEntry (); + if ((this.method & METHOD_TUNING_16) > 0) + entry.setTuning (StreamUtils.readSigned16 (in, true)); + else if ((this.method & METHOD_TUNING_8) > 0) + entry.setTuning ((byte) in.read ()); + if ((this.method & METHOD_VOLUME) > 0) + entry.setVolumeAdjust ((byte) in.read ()); + if ((this.method & METHOD_SAMPLE_ID) > 0) + entry.setSampleID (StreamUtils.readSigned16 (in, true)); + else + entry.setSampleID (this.sampleId); + if ((this.method & METHOD_SUB_SAMPLE) > 0) + entry.setSubSampleNumber (in.read ()); + return entry; + } + + + /** + * Write the object data (the part after the object name) to the stream. + * + * @return The object data + * @throws IOException Could not write the object + */ + public byte [] createObjectData () throws IOException + { + final ByteArrayOutputStream out = new ByteArrayOutputStream (); + + final int entrySize = getEntrySize (this.method); + StreamUtils.writeSigned16 (out, this.sampleId, true); + StreamUtils.writeSigned16 (out, this.method, true); + StreamUtils.writeSigned16 (out, this.basePitch, true); + StreamUtils.writeSigned16 (out, this.centsPerEntry, true); + StreamUtils.writeSigned16 (out, this.numEntries - 1, true); + StreamUtils.writeSigned16 (out, entrySize, true); + + final int tableSize = this.numEntries * entrySize; + for (int level = 0; level < NUM_LEVELS; level++) + StreamUtils.writeSigned16 (out, (NUM_LEVELS - level) * 2 + this.levelTableIndices[level] * tableSize, true); + + for (final KurzweilKeymapEntry [] entries: this.entryTables) + for (final KurzweilKeymapEntry entry: entries) + this.writeEntry (out, entry); + + return out.toByteArray (); + } + + + private void writeEntry (final OutputStream out, final KurzweilKeymapEntry entry) throws IOException + { + if ((this.method & METHOD_TUNING_16) > 0) + StreamUtils.writeSigned16 (out, entry.getTuning (), true); + else if ((this.method & METHOD_TUNING_8) > 0) + out.write (entry.getTuning ()); + if ((this.method & METHOD_VOLUME) > 0) + out.write (entry.getVolumeAdjust ()); + if ((this.method & METHOD_SAMPLE_ID) > 0) + StreamUtils.writeSigned16 (out, entry.getSampleID (), true); + if ((this.method & METHOD_SUB_SAMPLE) > 0) + out.write (entry.getSubSampleNumber ()); + } + + + /** + * Calculate the size of one entry in bytes from the method bits. + * + * @param method The method bits + * @return The entry size + */ + public static int getEntrySize (final int method) + { + int size = 0; + if ((method & METHOD_TUNING_16) > 0) + size += 2; + else if ((method & METHOD_TUNING_8) > 0) + size += 1; + if ((method & METHOD_VOLUME) > 0) + size += 1; + if ((method & METHOD_SAMPLE_ID) > 0) + size += 2; + if ((method & METHOD_SUB_SAMPLE) > 0) + size += 1; + return size; + } + + + /** + * Get the object ID. + * + * @return The ID + */ + public int getId () + { + return this.id; + } + + + /** + * Get the name of the keymap. + * + * @return The name + */ + public String getName () + { + return this.name; + } + + + /** + * Get the MIDI note to which an entry responds. + * + * @param entryIndex The index of the entry + * @return The MIDI note + */ + public int getNoteOfEntry (final int entryIndex) + { + return BASE_NOTE + (int) Math.round ((this.basePitch + entryIndex * (double) this.centsPerEntry) / 100.0); + } + + + /** + * Get the number of entries per table. + * + * @return The number of entries + */ + public int getNumberOfEntries () + { + return this.numEntries; + } + + + /** + * Get the entry tables. + * + * @return The tables + */ + public List getEntryTables () + { + return this.entryTables; + } + + + /** + * Add an entry table. + * + * @param entries The table with one entry per key position + * @return The index of the added table + */ + public int addEntryTable (final KurzweilKeymapEntry [] entries) + { + this.entryTables.add (entries); + return this.entryTables.size () - 1; + } + + + /** + * Get the index of the entry table which the given dynamic level uses. + * + * @param level The dynamic level (0..7) + * @return The table index + */ + public int getTableIndexOfLevel (final int level) + { + return this.levelTableIndices[level]; + } + + + /** + * Set the index of the entry table which the given dynamic level uses. + * + * @param level The dynamic level (0..7) + * @param tableIndex The table index + */ + public void setTableIndexOfLevel (final int level, final int tableIndex) + { + this.levelTableIndices[level] = tableIndex; + } +} diff --git a/src/main/java/de/mossgrabers/convertwithmoss/format/kurzweil/KurzweilKeymapEntry.java b/src/main/java/de/mossgrabers/convertwithmoss/format/kurzweil/KurzweilKeymapEntry.java new file mode 100644 index 00000000..8298012b --- /dev/null +++ b/src/main/java/de/mossgrabers/convertwithmoss/format/kurzweil/KurzweilKeymapEntry.java @@ -0,0 +1,132 @@ +// 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.kurzweil; + +/** + * One entry of a Kurzweil keymap. Assigns a sample recording (sample object + 1-based sub-sample + * number) and a tuning offset to one key position. Which of the fields are actually stored in the + * file is defined by the method bits of the keymap. + * + * @author Jürgen Moßgraber + */ +public class KurzweilKeymapEntry +{ + private int tuning; + private int volumeAdjust; + private int sampleID; + private int subSampleNumber; + + + /** + * Is the entry in use? Unused entries have a sub-sample number of 0. + * + * @return True if the entry references a sample + */ + public boolean isUsed () + { + return this.subSampleNumber > 0; + } + + + /** + * Check if another entry references the same sample recording with identical settings. + * + * @param entry The other entry + * @return True if identical + */ + public boolean isIdentical (final KurzweilKeymapEntry entry) + { + return this.sampleID == entry.sampleID && this.subSampleNumber == entry.subSampleNumber && this.tuning == entry.tuning && this.volumeAdjust == entry.volumeAdjust; + } + + + /** + * Get the tuning offset which is added to the chromatic key tracking. + * + * @return The tuning in cents + */ + public int getTuning () + { + return this.tuning; + } + + + /** + * Set the tuning offset which is added to the chromatic key tracking. + * + * @param tuning The tuning in cents + */ + public void setTuning (final int tuning) + { + this.tuning = tuning; + } + + + /** + * Get the volume adjustment (unit not confirmed, therefore not interpreted). + * + * @return The volume adjustment + */ + public int getVolumeAdjust () + { + return this.volumeAdjust; + } + + + /** + * Set the volume adjustment. + * + * @param volumeAdjust The volume adjustment + */ + public void setVolumeAdjust (final int volumeAdjust) + { + this.volumeAdjust = volumeAdjust; + } + + + /** + * Get the ID of the referenced sample object. + * + * @return The sample object ID + */ + public int getSampleID () + { + return this.sampleID; + } + + + /** + * Set the ID of the referenced sample object. + * + * @param sampleID The sample object ID + */ + public void setSampleID (final int sampleID) + { + this.sampleID = sampleID; + } + + + /** + * Get the 1-based index of the referenced header in the sample object. 0 marks an unused + * entry. For stereo samples this references the left channel header (1, 3, 5, ...). + * + * @return The sub-sample number + */ + public int getSubSampleNumber () + { + return this.subSampleNumber; + } + + + /** + * Set the 1-based index of the referenced header in the sample object. + * + * @param subSampleNumber The sub-sample number + */ + public void setSubSampleNumber (final int subSampleNumber) + { + this.subSampleNumber = subSampleNumber; + } +} diff --git a/src/main/java/de/mossgrabers/convertwithmoss/format/kurzweil/KurzweilObjectID.java b/src/main/java/de/mossgrabers/convertwithmoss/format/kurzweil/KurzweilObjectID.java new file mode 100644 index 00000000..e733239e --- /dev/null +++ b/src/main/java/de/mossgrabers/convertwithmoss/format/kurzweil/KurzweilObjectID.java @@ -0,0 +1,74 @@ +// 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.kurzweil; + +/** + * Object type constants and hash calculations for Kurzweil K2000/K2500/K2600 objects. The 16-bit + * hash of an object combines its type and ID: for the types up to 42 it is (type << 10) | + * ID with IDs from 0 to 1023 (usable on the device are 1 to 999). Other types use an 8-bit type + * with a mangled ID which is not interpreted here. + * + * @author Jürgen Moßgraber + */ +public class KurzweilObjectID +{ + /** The object type of a program. */ + public static final int TYPE_PROGRAM = 36; + /** The object type of a keymap. */ + public static final int TYPE_KEYMAP = 37; + /** The object type of a sample. */ + public static final int TYPE_SAMPLE = 38; + + /** The first object ID to use for newly created objects. */ + public static final int FIRST_ID = 200; + /** The highest object ID usable on the device. */ + public static final int LAST_ID = 999; + + + /** + * Private constructor for utility class. + */ + private KurzweilObjectID () + { + // Intentionally empty + } + + + /** + * Create the hash for an object type and ID. + * + * @param type The object type, e.g. TYPE_SAMPLE + * @param id The object ID (0-1023) + * @return The hash + */ + public static int createHash (final int type, final int id) + { + return type << 10 | id; + } + + + /** + * Get the object type from a hash. + * + * @param hash The hash + * @return The object type + */ + public static int getType (final int hash) + { + return (hash & 0x8000) > 0 ? hash >>> 10 : hash >>> 8; + } + + + /** + * Get the object ID from a hash. + * + * @param hash The hash + * @return The object ID + */ + public static int getID (final int hash) + { + return (hash & 0x8000) > 0 ? hash & 1023 : hash & 255; + } +} diff --git a/src/main/java/de/mossgrabers/convertwithmoss/format/kurzweil/KurzweilProgram.java b/src/main/java/de/mossgrabers/convertwithmoss/format/kurzweil/KurzweilProgram.java new file mode 100644 index 00000000..61ae7a41 --- /dev/null +++ b/src/main/java/de/mossgrabers/convertwithmoss/format/kurzweil/KurzweilProgram.java @@ -0,0 +1,332 @@ +// 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.kurzweil; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; + +import de.mossgrabers.convertwithmoss.file.StreamUtils; + + +/** + * A Kurzweil program object (type 36). Consists of a sequence of segments, each a tag byte + * followed by a fixed length data block. Only the segments needed to map layers to keymaps are + * interpreted; when creating a program the same segment values are written which KurzFiler uses + * for its generated instrument programs (verified to load on the devices). + * + * @author Jürgen Moßgraber + */ +public class KurzweilProgram +{ + private static final int TAG_PROGRAM = 8; + private static final int TAG_LAYER = 9; + private static final int TAG_FX = 15; + private static final int TAG_ENVELOPE_CONTROL = 32; + private static final int TAG_ENVELOPE = 33; + private static final int TAG_CALIBRATION = 64; + + /** A program which only uses K2000 features. */ + public static final int MODE_K2000 = 2; + + + /** One layer of a program: a keymap mapped to a key range. */ + public static class Layer + { + private int keymapID; + private int lowKey = 0; + private int highKey = 127; + private int transpose = 0; + + + /** + * Get the ID of the keymap object of this layer. + * + * @return The keymap object ID + */ + public int getKeymapID () + { + return this.keymapID; + } + + + /** + * Get the lowest MIDI note of the layer. + * + * @return The lowest note + */ + public int getLowKey () + { + return this.lowKey; + } + + + /** + * Get the highest MIDI note of the layer. + * + * @return The highest note + */ + public int getHighKey () + { + return this.highKey; + } + + + /** + * Get the transposition of the layer. + * + * @return The transposition in semi-tones + */ + public int getTranspose () + { + return this.transpose; + } + } + + + /** One segment: a tag with its fixed length data. */ + private record Segment (int tag, byte [] data) + { + // Intentionally empty + } + + + private int id; + private String name; + private final List segments = new ArrayList<> (); + + + /** + * Constructor for a new empty program. + * + * @param id The object ID + * @param name The name of the program, maximum 16 characters + */ + public KurzweilProgram (final int id, final String name) + { + this.id = id; + this.name = name; + } + + + /** + * Constructor. Reads the object data (the part after the object name) from the stream. + * + * @param id The object ID + * @param name The name of the program + * @param in The input stream to read from + * @throws IOException Could not read the object + */ + public KurzweilProgram (final int id, final String name, final InputStream in) throws IOException + { + this.id = id; + this.name = name; + + while (true) + { + final int tag = in.read (); + if (tag <= 0) + break; + final int length = getSegmentDataLength (tag); + final byte [] data = in.readNBytes (length); + if (data.length < length) + break; + this.segments.add (new Segment (tag, data)); + } + } + + + /** + * Get the length of the data block of a segment. + * + * @param tag The tag of the segment + * @return The length in bytes + */ + private static int getSegmentDataLength (final int tag) + { + if (tag == TAG_PROGRAM || tag == TAG_LAYER) + return 15; + if (tag == TAG_FX) + return 7; + + return switch (tag & 0xF8) + { + // ASR and LFO blocks + case 16 -> 7; + // Function blocks + case 24 -> 3; + // Envelope control, envelope and impact blocks + case 32 -> 15; + // Calibration blocks + case 64 -> 31; + // Output blocks + case 80 -> 15; + // KDFX blocks + case 104 -> 7; + // KB3 blocks + case 120 -> 31; + default -> 0; + }; + } + + + /** + * Write the object data (the part after the object name) to the stream. + * + * @return The object data + * @throws IOException Could not write the object + */ + public byte [] createObjectData () throws IOException + { + final ByteArrayOutputStream out = new ByteArrayOutputStream (); + for (final Segment segment: this.segments) + { + out.write (segment.tag ()); + out.write (segment.data ()); + } + // The segment list ends with an empty word + StreamUtils.writeSigned16 (out, 0, true); + return out.toByteArray (); + } + + + /** + * Get the object ID. + * + * @return The ID + */ + public int getId () + { + return this.id; + } + + + /** + * Get the name of the program. + * + * @return The name + */ + public String getName () + { + return this.name; + } + + + /** + * Get the layers of the program with their keymap references. A layer is opened by a layer + * segment; the following calibration segment holds the ID of its keymap and its transposition. + * + * @return The layers + */ + public List getLayers () + { + final List layers = new ArrayList<> (); + Layer currentLayer = null; + for (final Segment segment: this.segments) + if (segment.tag () == TAG_LAYER) + { + currentLayer = new Layer (); + currentLayer.lowKey = segment.data ()[3] & 0x7F; + currentLayer.highKey = segment.data ()[4] & 0x7F; + layers.add (currentLayer); + } + else if (segment.tag () == TAG_CALIBRATION && currentLayer != null) + { + currentLayer.transpose = segment.data ()[1]; + currentLayer.keymapID = (segment.data ()[7] & 0xFF) << 8 | segment.data ()[8] & 0xFF; + if (currentLayer.keymapID == 0) + currentLayer.keymapID = (segment.data ()[11] & 0xFF) << 8 | segment.data ()[12] & 0xFF; + } + return layers; + } + + + /** + * Add the program block. Must be called once before adding layers. + */ + public void addProgramBlock () + { + final byte [] data = new byte [15]; + data[0] = MODE_K2000; + // data[1] holds the number of layers and is increased by addLayer + // Bend range + data[3] = 0x37; + // Portamento + data[4] = 64; + this.segments.add (new Segment (TAG_PROGRAM, data)); + } + + + /** + * Add a layer which plays the given keymap on the full key and velocity range. Writes the + * same segment sequence as KurzFiler: layer, envelope control, amplitude envelope, + * calibration and the four output blocks. + * + * @param keymapID The ID of the keymap object of the layer + * @param isStereo True if the keymap references stereo samples + */ + public void addLayer (final int keymapID, final boolean isStereo) + { + byte [] data = new byte [15]; + data[1] = 0x18; + // Low and high key + data[3] = 0; + data[4] = 0x7F; + // Low and high velocity + data[5] = 0; + data[6] = 0x7F; + // Enable flags + data[8] = (byte) (isStereo ? 0x24 : 0x04); + this.segments.add (new Segment (TAG_LAYER, data)); + + // Envelope control: all zero = user envelope instead of the natural one + this.segments.add (new Segment (TAG_ENVELOPE_CONTROL, new byte [15])); + + // Amplitude envelope: full sustain + data = new byte [15]; + data[1] = 100; + data[7] = 100; + this.segments.add (new Segment (TAG_ENVELOPE, data)); + + // Calibration: references the keymap (twice, as KurzFiler does) + data = new byte [31]; + data[0] = 0x7F; + // data[1] is the keymap transpose + data[3] = 0x2B; + data[7] = (byte) (keymapID >>> 8 & 0xFF); + data[8] = (byte) (keymapID & 0xFF); + data[11] = (byte) (keymapID >>> 8 & 0xFF); + data[12] = (byte) (keymapID & 0xFF); + data[29] = 1; + this.segments.add (new Segment (TAG_CALIBRATION, data)); + + // The output blocks + data = new byte [15]; + data[0] = 62; + this.segments.add (new Segment (0x50, data)); + data = new byte [15]; + data[0] = 60; + this.segments.add (new Segment (0x51, data)); + data = new byte [15]; + data[0] = 60; + this.segments.add (new Segment (0x52, data)); + data = new byte [15]; + data[0] = 1; + data[2] = 0x70; + data[13] = 4; + data[14] = (byte) (isStereo ? 0x90 : 0x00); + this.segments.add (new Segment (0x53, data)); + + // Increase the layer counter in the program block + for (final Segment segment: this.segments) + if (segment.tag () == TAG_PROGRAM) + { + segment.data ()[1]++; + break; + } + } +} diff --git a/src/main/java/de/mossgrabers/convertwithmoss/format/kurzweil/KurzweilSample.java b/src/main/java/de/mossgrabers/convertwithmoss/format/kurzweil/KurzweilSample.java new file mode 100644 index 00000000..3aa13060 --- /dev/null +++ b/src/main/java/de/mossgrabers/convertwithmoss/format/kurzweil/KurzweilSample.java @@ -0,0 +1,227 @@ +// 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.kurzweil; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; + +import de.mossgrabers.convertwithmoss.file.StreamUtils; + + +/** + * A Kurzweil sample object (type 38). Contains one sample header per recording. A sample with + * several headers is a multi-root sample; for stereo samples the headers form left/right pairs + * (even index = left channel). + * + * @author Jürgen Moßgraber + */ +public class KurzweilSample +{ + private static final int FLAG_STEREO = 1; + + /** The default natural envelope which KurzFiler writes: 2 records of 6 signed 16-bit values. */ + private static final int [][] DEFAULT_ENVELOPE = + { + { + -1, + 1, + 0, + 0, + -1600, + 0 + }, + { + -1, + 1, + 0, + 0, + -1600, + 0 + } + }; + + private int id; + private String name; + private int baseID = 1; + private int flags = 0; + private int copyID = 0; + + private final List headers = new ArrayList<> (); + + + /** + * Constructor for a new empty sample object. + * + * @param id The object ID + * @param name The name of the sample, maximum 16 characters + */ + public KurzweilSample (final int id, final String name) + { + this.id = id; + this.name = name; + } + + + /** + * Constructor. Reads the object data (the part after the object name) from the stream. + * + * @param id The object ID + * @param name The name of the sample + * @param in The input stream to read from + * @throws IOException Could not read the object + */ + public KurzweilSample (final int id, final String name, final InputStream in) throws IOException + { + this.id = id; + this.name = name; + + this.baseID = StreamUtils.readSigned16 (in, true); + final int numHeaders = StreamUtils.readSigned16 (in, true); + // The offset to the first header - always 8 + StreamUtils.readSigned16 (in, true); + this.flags = in.read (); + // 1 unused byte, the copy ID and 2 unused bytes + in.read (); + this.copyID = StreamUtils.readSigned16 (in, true); + StreamUtils.readSigned16 (in, true); + + for (int i = 0; i <= numHeaders; i++) + this.headers.add (new KurzweilSampleHeader (in)); + + // The rest of the object data holds the natural envelope records which are not + // interpreted and re-created with default values when writing + } + + + /** + * Write the object data (the part after the object name) to the stream. + * + * @param wordOffset The word offset in the sample data region at which the sample data of + * this object will be placed + * @return The object data + * @throws IOException Could not write the object + */ + public byte [] createObjectData (final int wordOffset) throws IOException + { + final ByteArrayOutputStream out = new ByteArrayOutputStream (); + + StreamUtils.writeSigned16 (out, this.baseID, true); + StreamUtils.writeSigned16 (out, this.headers.size () - 1, true); + StreamUtils.writeSigned16 (out, 8, true); + out.write (this.flags); + out.write (0); + StreamUtils.writeSigned16 (out, this.copyID, true); + StreamUtils.writeSigned16 (out, 0, true); + + // Every header points both of its envelope offset fields at the first envelope record + // which starts right after the last header (offsets are relative to the fields at bytes + // 24/26 of the header) + int offset = wordOffset; + final int numHeaders = this.headers.size (); + for (int i = 0; i < numHeaders; i++) + { + final KurzweilSampleHeader header = this.headers.get (i); + final int distanceToEnvelope = (numHeaders - 1 - i) * KurzweilSampleHeader.LENGTH; + header.setEnvelopeOffsets (distanceToEnvelope + 8, distanceToEnvelope + 6); + header.write (out, offset); + if (header.hasSampleData ()) + offset += header.getNumberOfFrames (); + } + + for (final int [] envelope: DEFAULT_ENVELOPE) + for (final int value: envelope) + StreamUtils.writeSigned16 (out, value, true); + + return out.toByteArray (); + } + + + /** + * Get the object ID. + * + * @return The ID + */ + public int getId () + { + return this.id; + } + + + /** + * Get the name of the sample. + * + * @return The name + */ + public String getName () + { + return this.name; + } + + + /** + * Is this a stereo sample? If true the headers form left/right pairs. + * + * @return True if stereo + */ + public boolean isStereo () + { + return (this.flags & FLAG_STEREO) > 0; + } + + + /** + * Set the stereo flag. + * + * @param isStereo True for a stereo sample + */ + public void setStereo (final boolean isStereo) + { + this.flags = isStereo ? FLAG_STEREO : 0; + } + + + /** + * Get all sample headers. + * + * @return The headers + */ + public List getHeaders () + { + return this.headers; + } + + + /** + * Add a sample header. + * + * @param header The header to add + * @return The 1-based index of the added header (= the sub-sample number to reference it from + * a keymap entry) + */ + public int addHeader (final KurzweilSampleHeader header) + { + this.headers.add (header); + return this.headers.size (); + } + + + /** + * Get the number of words which the sample data of all headers occupies in the sample data + * region. + * + * @return The number of words + */ + public int getNumberOfWords () + { + int words = 0; + for (final KurzweilSampleHeader header: this.headers) + if (header.hasSampleData ()) + words += header.getNumberOfFrames (); + return words; + } +} diff --git a/src/main/java/de/mossgrabers/convertwithmoss/format/kurzweil/KurzweilSampleHeader.java b/src/main/java/de/mossgrabers/convertwithmoss/format/kurzweil/KurzweilSampleHeader.java new file mode 100644 index 00000000..3737db8d --- /dev/null +++ b/src/main/java/de/mossgrabers/convertwithmoss/format/kurzweil/KurzweilSampleHeader.java @@ -0,0 +1,338 @@ +// 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.kurzweil; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +import de.mossgrabers.convertwithmoss.file.StreamUtils; + + +/** + * One sample header ('soundfile head') of a Kurzweil sample object. Describes one recording: its + * root key, loop and the position of its 16-bit big-endian PCM words in the sample data region of + * the file. A sample object contains one header per root key and for stereo samples one pair of + * headers (left/right) per root key. All positions are counted in 16-bit words; the end position + * is inclusive. + * + * @author Jürgen Moßgraber + */ +public class KurzweilSampleHeader +{ + /** The length of a sample header in bytes. */ + public static final int LENGTH = 32; + + private static final int FLAG_NOT_LOOPED = 0x80; + private static final int FLAG_DATA_PRESENT = 0x40; + /** Loop on, data present, RAM based - the flags KurzFiler writes for imported samples. */ + private static final int FLAGS_IMPORTED = 0x70; + + /** The maximum playback rate of the device in Hertz, limits the upward transposition. */ + private static final long MAX_PLAYBACK_RATE = 96000; + + private static final long NANOS_PER_SECOND = 1000000000L; + + private int rootKey; + private int flags; + private int volumeAdjust; + private int altVolumeAdjust; + private int maxPitch; + private int offsetToName; + private int sampleStart; + private int altSampleStart; + private int loopStart; + private int sampleEnd; + private int offsetToEnvelope; + private int altOffsetToEnvelope; + private int samplePeriod; + + private byte [] sampleData; + + + /** + * Default constructor. + */ + public KurzweilSampleHeader () + { + this.flags = FLAGS_IMPORTED; + } + + + /** + * Constructor. Reads the header from the stream. + * + * @param in The input stream to read from + * @throws IOException Could not read the header + */ + public KurzweilSampleHeader (final InputStream in) throws IOException + { + this.rootKey = in.read (); + this.flags = in.read (); + this.volumeAdjust = (byte) in.read (); + this.altVolumeAdjust = (byte) in.read (); + this.maxPitch = StreamUtils.readSigned16 (in, true); + this.offsetToName = StreamUtils.readSigned16 (in, true); + this.sampleStart = StreamUtils.readSigned32 (in, true); + this.altSampleStart = StreamUtils.readSigned32 (in, true); + this.loopStart = StreamUtils.readSigned32 (in, true); + this.sampleEnd = StreamUtils.readSigned32 (in, true); + this.offsetToEnvelope = StreamUtils.readSigned16 (in, true); + this.altOffsetToEnvelope = StreamUtils.readSigned16 (in, true); + this.samplePeriod = StreamUtils.readSigned32 (in, true); + } + + + /** + * Extract the sample data of this header from the sample data region of the file and + * normalize all positions to be relative to the extracted data. + * + * @param fileData The content of the whole file + * @param dataOffset The offset of the sample data region in the file + * @return True if the data could be extracted + */ + public boolean extractSampleData (final byte [] fileData, final int dataOffset) + { + if (!this.needsSampleData ()) + return true; + + final long startByte = dataOffset + 2L * this.sampleStart; + final long endByte = dataOffset + 2L * (this.sampleEnd + 1L); + if (this.sampleStart < 0 || this.sampleEnd < this.sampleStart || endByte > fileData.length) + return false; + + this.sampleData = new byte [(int) (endByte - startByte)]; + System.arraycopy (fileData, (int) startByte, this.sampleData, 0, this.sampleData.length); + + this.sampleEnd -= this.sampleStart; + this.loopStart = Math.clamp (this.loopStart - this.sampleStart, 0, this.sampleEnd); + this.altSampleStart -= this.sampleStart; + if (this.altSampleStart < 0 || this.altSampleStart > this.sampleEnd) + this.altSampleStart = 0; + this.sampleStart = 0; + return true; + } + + + /** + * Write the header to the stream. The positions are written relative to the given word offset + * at which the sample data of this header will be placed in the sample data region. + * + * @param out The output stream to write to + * @param wordOffset The word offset of the data of this header in the sample data region + * @throws IOException Could not write the header + */ + public void write (final OutputStream out, final int wordOffset) throws IOException + { + out.write (this.rootKey); + out.write (this.flags); + out.write (this.volumeAdjust); + out.write (this.altVolumeAdjust); + StreamUtils.writeSigned16 (out, this.maxPitch, true); + StreamUtils.writeSigned16 (out, this.offsetToName, true); + StreamUtils.writeSigned32 (out, wordOffset + this.sampleStart, true); + StreamUtils.writeSigned32 (out, wordOffset + this.altSampleStart, true); + StreamUtils.writeSigned32 (out, wordOffset + this.loopStart, true); + StreamUtils.writeSigned32 (out, wordOffset + this.sampleEnd, true); + StreamUtils.writeSigned16 (out, this.offsetToEnvelope, true); + StreamUtils.writeSigned16 (out, this.altOffsetToEnvelope, true); + StreamUtils.writeSigned32 (out, this.samplePeriod, true); + } + + + /** + * Does the header reference sample data stored in the file? If false the header references + * device ROM which cannot be converted. + * + * @return True if the sample data is stored in the file + */ + public boolean needsSampleData () + { + return (this.flags & FLAG_DATA_PRESENT) > 0; + } + + + /** + * Is there extracted/assigned sample data? + * + * @return True if sample data is present + */ + public boolean hasSampleData () + { + return this.sampleData != null; + } + + + /** + * Is the sample looped? The flag is inverted in the file. A 'loop' which only consists of the + * last word (as written for one-shots) is not considered a loop. + * + * @return True if looped + */ + public boolean isLooped () + { + return (this.flags & FLAG_NOT_LOOPED) == 0 && this.loopStart < this.sampleEnd; + } + + + /** + * Get the sample rate calculated from the sample period. Values which are off by rounding are + * snapped to the matching standard rate. + * + * @return The sample rate in Hertz + */ + public int getSampleRate () + { + if (this.samplePeriod <= 0) + return 44100; + final int rate = (int) Math.round ((double) NANOS_PER_SECOND / this.samplePeriod); + for (final int standardRate: new int [] + { + 8000, + 11025, + 16000, + 22050, + 24000, + 32000, + 44100, + 48000, + 96000 + }) + if (Math.abs (rate - standardRate) <= 2) + return standardRate; + return rate; + } + + + /** + * Set the sample period from a sample rate. + * + * @param sampleRate The sample rate in Hertz + */ + public void setSampleRate (final int sampleRate) + { + this.samplePeriod = (int) Math.ceil ((double) NANOS_PER_SECOND / sampleRate); + } + + + /** + * Get the root key. + * + * @return The MIDI note of the recorded pitch + */ + public int getRootKey () + { + return this.rootKey; + } + + + /** + * Set the root key. Updates the maximum pitch which depends on it (the sample can be + * transposed upwards until the playback rate reaches 96kHz). Set the sample rate first! + * + * @param rootKey The MIDI note of the recorded pitch + */ + public void setRootKey (final int rootKey) + { + this.rootKey = rootKey; + this.maxPitch = (int) Math.ceil (1200.0 * Math.log (MAX_PLAYBACK_RATE / (double) NANOS_PER_SECOND * this.samplePeriod) / Math.log (2.0)) + 100 * rootKey - 1200; + } + + + /** + * Get the start of the playback in words relative to the sample data. + * + * @return The start position + */ + public int getSampleStart () + { + return this.sampleStart; + } + + + /** + * Get the start of the loop in words relative to the sample data. + * + * @return The loop start position + */ + public int getLoopStart () + { + return this.loopStart; + } + + + /** + * Set the start of the loop in words relative to the sample data. The loop always ends at the + * sample end. Setting the loop start to the sample end effectively disables the loop. + * + * @param loopStart The loop start position + */ + public void setLoopStart (final int loopStart) + { + this.loopStart = loopStart; + } + + + /** + * Get the inclusive end of the sample in words relative to the sample data. + * + * @return The end position + */ + public int getSampleEnd () + { + return this.sampleEnd; + } + + + /** + * Get the number of sample words (= mono frames). + * + * @return The number of words + */ + public int getNumberOfFrames () + { + return this.sampleEnd - this.sampleStart + 1; + } + + + /** + * Get the 16-bit big-endian PCM sample data. + * + * @return The sample data or null if the header references ROM + */ + public byte [] getSampleData () + { + return this.sampleData; + } + + + /** + * Set the 16-bit big-endian PCM sample data. Updates the sample end accordingly. + * + * @param sampleData The sample data + */ + public void setSampleData (final byte [] sampleData) + { + this.sampleData = sampleData; + this.sampleStart = 0; + this.sampleEnd = sampleData.length / 2 - 1; + this.loopStart = this.sampleEnd; + this.flags = FLAGS_IMPORTED; + } + + + /** + * Set the byte offsets from the two envelope offset fields to the natural envelope of the + * sample object. + * + * @param offsetToEnvelope The offset for the normal envelope field + * @param altOffsetToEnvelope The offset for the alternative envelope field + */ + public void setEnvelopeOffsets (final int offsetToEnvelope, final int altOffsetToEnvelope) + { + this.offsetToEnvelope = offsetToEnvelope; + this.altOffsetToEnvelope = altOffsetToEnvelope; + } +} diff --git a/src/main/resources/Strings.properties b/src/main/resources/Strings.properties index 8383cf8a..e8a291d3 100644 --- a/src/main/resources/Strings.properties +++ b/src/main/resources/Strings.properties @@ -380,6 +380,17 @@ IDS_S3P_INVALID_MIDI_MESSAGE=This is not a proper S1000/S3000 MIDI SysEx message IDS_S3P_SAMPLE_INFO_MISSING=The sample does not contain sample information: %1\n IDS_S3P_BROKEN_WAV=Could not read WAV file: %1\n +IDS_KURZWEIL_READING=Reading %1 '%2'.\n +IDS_KURZWEIL_KEYMAP_MISSING=Keymap with ID %1 referenced by program '%2' not found. Skipped.\n +IDS_KURZWEIL_SAMPLE_MISSING=Sample with ID %1 referenced by keymap '%2' not found. Skipped.\n +IDS_KURZWEIL_BAD_SUB_SAMPLE=Sub-sample %1 does not exist in sample '%2'. Skipped.\n +IDS_KURZWEIL_ROM_SAMPLE=Sample '%1' references sample data in the device ROM which cannot be converted. Skipped.\n +IDS_KURZWEIL_NO_ZONES=No convertible sample zones found in '%1'. Skipped.\n +IDS_KURZWEIL_TOO_MANY_OBJECTS=All 800 sample object IDs are in use. Zone '%1' skipped.\n +IDS_KURZWEIL_OVERLAPPING_ZONES=%1 keys are covered by several zones on the same velocity level in '%2'. Only the first zone is kept.\n +IDS_KURZWEIL_KEYS_OUT_OF_RANGE=Some zones of '%1' extend below the keymap range (C0). The keys below are dropped.\n +IDS_KURZWEIL_LIBRARY_SPLIT=The %1 multi-samples do not fit into one file. Writing %2 files.\n + IDS_MV8000_READING_PATCH=Reading patch '%1'.\n IDS_MV8000_SAMPLE_MISSING=Sample with ID %1 referenced by partial '%2' not found. Skipped.\n IDS_MV8000_ZONE_OUT_OF_RANGE=The key range of zone '%1' is outside of the MV-8000 note range. Skipped.\n From a60e5ee2cf9ae46f0dfc590f6fc114e2edcfa010 Mon Sep 17 00:00:00 2001 From: Douglas Carmichael Date: Wed, 15 Jul 2026 15:36:00 -0400 Subject: [PATCH 2/4] Add a target device selection to the Kurzweil destination settings A drop-down selects the device family for which the file is named: K2000 (krz), K2500 (k25) or K2600 (k26). Since the written objects use only K2000 features and load on all three families, the selection only sets the file extension. On the command line the device is set with -p KurzweilTargetDevice=K2000|K2500|K2600 (the extensions are accepted as well), the default is K2000. --- documentation/README-FORMATS.md | 6 +- .../format/kurzweil/KurzweilCreator.java | 15 +- .../format/kurzweil/KurzweilCreatorUI.java | 160 ++++++++++++++++++ src/main/resources/Strings.properties | 4 + 4 files changed, 176 insertions(+), 9 deletions(-) create mode 100644 src/main/java/de/mossgrabers/convertwithmoss/format/kurzweil/KurzweilCreatorUI.java diff --git a/documentation/README-FORMATS.md b/documentation/README-FORMATS.md index bea2c6f1..325ecb0a 100644 --- a/documentation/README-FORMATS.md +++ b/documentation/README-FORMATS.md @@ -409,7 +409,11 @@ The Kurzweil K2000 (1991), K2500 and K2600 samplers/synthesizers share one objec When reading, each program becomes one multi-sample: its layers reference keymaps from which the key ranges, velocity levels, root keys, tunings, loops and the 16-bit sample data are read. Keymaps and samples which are not referenced by any program are converted as multi-samples of their own. Many factory and commercial K-series files map samples from the device ROM which is not present in the file; such zones cannot be converted and are skipped with a note. -When writing, a *.krz* file is created which uses only K2000 features and therefore loads on all three device families. Each multi-sample becomes a program with one layer and a keymap; the velocity layers of the source are quantized onto the 8 dynamic levels of the keymap. One sample object is written per zone (16-bit, the sample rate is kept). Since the device plays a loop until the end of the sample, the data after the loop end is cut off. If any zone is stereo, all samples of the program are written as stereo pairs. The keymap covers MIDI notes 12-127 (C0-G9 in Kurzweil terms), keys below are dropped. Several multi-samples can be written into one file as a library; if the object IDs (200-999 per type) do not suffice, multiple files are created. +When writing, a file is created which uses only K2000 features and therefore loads on all three device families. Each multi-sample becomes a program with one layer and a keymap; the velocity layers of the source are quantized onto the 8 dynamic levels of the keymap. One sample object is written per zone (16-bit, the sample rate is kept). Since the device plays a loop until the end of the sample, the data after the loop end is cut off. If any zone is stereo, all samples of the program are written as stereo pairs. The keymap covers MIDI notes 12-127 (C0-G9 in Kurzweil terms), keys below are dropped. Several multi-samples can be written into one file as a library; if the object IDs (200-999 per type) do not suffice, multiple files are created. + +#### Destination Options + +* Target Device: Selects the device family for which the file is named: *K2000 (krz)*, *K2500 (k25)* or *K2600 (k26)*. Since the written objects use only K2000 features, the selection only sets the file extension. ## Logic EXS24 diff --git a/src/main/java/de/mossgrabers/convertwithmoss/format/kurzweil/KurzweilCreator.java b/src/main/java/de/mossgrabers/convertwithmoss/format/kurzweil/KurzweilCreator.java index 0fc73cf5..7cd5f00a 100644 --- a/src/main/java/de/mossgrabers/convertwithmoss/format/kurzweil/KurzweilCreator.java +++ b/src/main/java/de/mossgrabers/convertwithmoss/format/kurzweil/KurzweilCreator.java @@ -23,20 +23,19 @@ import de.mossgrabers.convertwithmoss.core.model.IGroup; import de.mossgrabers.convertwithmoss.core.model.ISampleLoop; import de.mossgrabers.convertwithmoss.core.model.ISampleZone; -import de.mossgrabers.convertwithmoss.core.settings.EmptySettingsUI; import de.mossgrabers.convertwithmoss.file.AudioFileUtils; import de.mossgrabers.convertwithmoss.file.wav.WaveFile; /** - * Creator for Kurzweil K2000/K2500/K2600 files. Writes a .krz file which uses only K2000 features - * and therefore loads on all three device families. Each multi-sample becomes a program with one - * layer, a keymap and one sample object per zone; the velocity layers are mapped onto the 8 - * dynamic levels of the keymap. + * Creator for Kurzweil K2000/K2500/K2600 files. The written files use only K2000 features and + * therefore load on all three device families; the selected target device sets the file extension + * (.krz, .k25 or .k26). Each multi-sample becomes a program with one layer, a keymap and one + * sample object per zone; the velocity layers are mapped onto the 8 dynamic levels of the keymap. * * @author Jürgen Moßgraber */ -public class KurzweilCreator extends AbstractCreator +public class KurzweilCreator extends AbstractCreator { private static final DestinationAudioFormat DESTINATION_FORMAT = new DestinationAudioFormat (new int [] { @@ -69,7 +68,7 @@ private static class PreparedZone */ public KurzweilCreator (final INotifier notifier) { - super ("Kurzweil K2x00", "Kurzweil", notifier, EmptySettingsUI.INSTANCE); + super ("Kurzweil K2x00", "Kurzweil", notifier, new KurzweilCreatorUI ()); } @@ -127,7 +126,7 @@ private void writeFile (final File destinationFolder, final String name, final K if (kurzweilFile.getPrograms ().isEmpty ()) return; - final File outputFile = this.createUniqueFilename (destinationFolder, createSafeFilename (name), "krz"); + final File outputFile = this.createUniqueFilename (destinationFolder, createSafeFilename (name), this.settingsConfiguration.getTargetDevice ().getExtension ()); this.notifier.log ("IDS_NOTIFY_STORING", outputFile.getAbsolutePath ()); try (final OutputStream out = new BufferedOutputStream (new FileOutputStream (outputFile))) { diff --git a/src/main/java/de/mossgrabers/convertwithmoss/format/kurzweil/KurzweilCreatorUI.java b/src/main/java/de/mossgrabers/convertwithmoss/format/kurzweil/KurzweilCreatorUI.java new file mode 100644 index 00000000..46ed57ee --- /dev/null +++ b/src/main/java/de/mossgrabers/convertwithmoss/format/kurzweil/KurzweilCreatorUI.java @@ -0,0 +1,160 @@ +// 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.kurzweil; + +import java.util.Locale; +import java.util.Map; + +import de.mossgrabers.convertwithmoss.core.INotifier; +import de.mossgrabers.convertwithmoss.core.settings.ICoreTaskSettings; +import de.mossgrabers.tools.ui.BasicConfig; +import de.mossgrabers.tools.ui.Functions; +import de.mossgrabers.tools.ui.panel.BoxPanel; +import javafx.geometry.Orientation; +import javafx.scene.control.ComboBox; +import javafx.scene.layout.Pane; + + +/** + * Settings for the Kurzweil creator. + * + * @author Jürgen Moßgraber + */ +public class KurzweilCreatorUI implements ICoreTaskSettings +{ + /** + * The target device family. Since the created files use only K2000 features, the selection + * only sets the matching file extension. + */ + public enum TargetDevice + { + /** Kurzweil K2000. */ + K2000 ("krz"), + /** Kurzweil K2500. */ + K2500 ("k25"), + /** Kurzweil K2600. */ + K2600 ("k26"); + + + private final String extension; + + + private TargetDevice (final String extension) + { + this.extension = extension; + } + + + /** + * Get the file extension of the device family. + * + * @return The extension without a dot + */ + public String getExtension () + { + return this.extension; + } + } + + + private static final String KURZWEIL_TARGET_DEVICE = "KurzweilTargetDevice"; + + private ComboBox targetDeviceBox; + private TargetDevice targetDevice = TargetDevice.K2000; + + + /** {@inheritDoc} */ + @Override + public Pane getEditPane () + { + final BoxPanel panel = new BoxPanel (Orientation.VERTICAL); + + panel.createSeparator ("@IDS_KURZWEIL_TARGET_DEVICE"); + this.targetDeviceBox = new ComboBox<> (); + this.targetDeviceBox.getItems ().addAll (Functions.getText ("@IDS_KURZWEIL_DEVICE_K2000"), Functions.getText ("@IDS_KURZWEIL_DEVICE_K2500"), Functions.getText ("@IDS_KURZWEIL_DEVICE_K2600")); + this.targetDeviceBox.setMaxWidth (Double.MAX_VALUE); + panel.addComponent (this.targetDeviceBox); + + return panel.getPane (); + } + + + /** {@inheritDoc} */ + @Override + public void loadSettings (final BasicConfig config) + { + this.targetDeviceBox.getSelectionModel ().select (Math.clamp (config.getInteger (KURZWEIL_TARGET_DEVICE, 0), 0, TargetDevice.values ().length - 1)); + } + + + /** {@inheritDoc} */ + @Override + public void saveSettings (final BasicConfig config) + { + config.setInteger (KURZWEIL_TARGET_DEVICE, this.targetDeviceBox.getSelectionModel ().getSelectedIndex ()); + } + + + /** {@inheritDoc} */ + @Override + public boolean checkSettingsUI (final INotifier notifier) + { + final int selected = this.targetDeviceBox.getSelectionModel ().getSelectedIndex (); + this.targetDevice = TargetDevice.values ()[Math.clamp (selected, 0, TargetDevice.values ().length - 1)]; + return true; + } + + + /** {@inheritDoc} */ + @Override + public boolean checkSettingsCLI (final INotifier notifier, final Map parameters) + { + final String value = parameters.remove (KURZWEIL_TARGET_DEVICE); + if (value == null) + { + this.targetDevice = TargetDevice.K2000; + return true; + } + + switch (value.trim ().toLowerCase (Locale.US)) + { + case "k2000", "krz": + this.targetDevice = TargetDevice.K2000; + break; + case "k2500", "k25": + this.targetDevice = TargetDevice.K2500; + break; + case "k2600", "k26": + this.targetDevice = TargetDevice.K2600; + break; + default: + notifier.logError ("IDS_CLI_UNKNOWN_OUTPUT_FORMAT", value); + return false; + } + return true; + } + + + /** {@inheritDoc} */ + @Override + public String [] getCLIParameterNames () + { + return new String [] + { + KURZWEIL_TARGET_DEVICE + }; + } + + + /** + * Get the selected target device. + * + * @return The target device + */ + public TargetDevice getTargetDevice () + { + return this.targetDevice; + } +} diff --git a/src/main/resources/Strings.properties b/src/main/resources/Strings.properties index e8a291d3..11cc1113 100644 --- a/src/main/resources/Strings.properties +++ b/src/main/resources/Strings.properties @@ -390,6 +390,10 @@ IDS_KURZWEIL_TOO_MANY_OBJECTS=All 800 sample object IDs are in use. Zone '%1' sk IDS_KURZWEIL_OVERLAPPING_ZONES=%1 keys are covered by several zones on the same velocity level in '%2'. Only the first zone is kept.\n IDS_KURZWEIL_KEYS_OUT_OF_RANGE=Some zones of '%1' extend below the keymap range (C0). The keys below are dropped.\n IDS_KURZWEIL_LIBRARY_SPLIT=The %1 multi-samples do not fit into one file. Writing %2 files.\n +IDS_KURZWEIL_TARGET_DEVICE=Target Device +IDS_KURZWEIL_DEVICE_K2000=K2000 (krz) +IDS_KURZWEIL_DEVICE_K2500=K2500 (k25) +IDS_KURZWEIL_DEVICE_K2600=K2600 (k26) IDS_MV8000_READING_PATCH=Reading patch '%1'.\n IDS_MV8000_SAMPLE_MISSING=Sample with ID %1 referenced by partial '%2' not found. Skipped.\n From 5dcc2095fc27835e0e8e2e84e1d4e81580f7ec33 Mon Sep 17 00:00:00 2001 From: Douglas Carmichael Date: Wed, 15 Jul 2026 16:23:38 -0400 Subject: [PATCH 3/4] Convert the Kurzweil sample volume adjust and limit the rate to 96 kHz The K2600 manual documents the Volume Adjust parameter of a sample with a range of -64.0 to +63.5 dB, which confirms that the volume adjust byte of the sample header holds 0.5 dB steps. The zone gain is now written into it and read back from it. The manual also documents the maximum sample playback rate of the devices as 96 kHz (and the current Kurzweil range - K2700, PC4, Forte, which loads K2x00 files as well - caps its own audio imports at 96 kHz). Samples with higher rates are now down-sampled to 96 kHz with the sample positions re-calculated accordingly. --- documentation/README-FORMATS.md | 4 ++-- documentation/design/KURZWEIL_FORMAT.md | 14 +++++++---- .../format/kurzweil/KurzweilCreator.java | 9 +++++++- .../format/kurzweil/KurzweilDetector.java | 2 ++ .../format/kurzweil/KurzweilSampleHeader.java | 23 +++++++++++++++++++ 5 files changed, 44 insertions(+), 8 deletions(-) diff --git a/documentation/README-FORMATS.md b/documentation/README-FORMATS.md index 325ecb0a..42dbbeef 100644 --- a/documentation/README-FORMATS.md +++ b/documentation/README-FORMATS.md @@ -407,9 +407,9 @@ Since the format supports only one group of a multi-sample, multiple destination The Kurzweil K2000 (1991), K2500 and K2600 samplers/synthesizers share one object file format with the extensions *.krz*, *.k25* and *.k26*. A file is a bank of numbered objects: programs (presets), keymaps (the mapping of sample recordings across the keys and the 8 dynamic velocity levels) and samples, which may contain several recordings (multiple root keys, stereo pairs). The layout was derived from the source code of the GPL tool KurzFiler by Marc Halbruegge (see *documentation/design/KURZWEIL_FORMAT.md*). -When reading, each program becomes one multi-sample: its layers reference keymaps from which the key ranges, velocity levels, root keys, tunings, loops and the 16-bit sample data are read. Keymaps and samples which are not referenced by any program are converted as multi-samples of their own. Many factory and commercial K-series files map samples from the device ROM which is not present in the file; such zones cannot be converted and are skipped with a note. +When reading, each program becomes one multi-sample: its layers reference keymaps from which the key ranges, velocity levels, root keys, tunings, volume adjusts, loops and the 16-bit sample data are read. Keymaps and samples which are not referenced by any program are converted as multi-samples of their own. Many factory and commercial K-series files map samples from the device ROM which is not present in the file; such zones cannot be converted and are skipped with a note. -When writing, a file is created which uses only K2000 features and therefore loads on all three device families. Each multi-sample becomes a program with one layer and a keymap; the velocity layers of the source are quantized onto the 8 dynamic levels of the keymap. One sample object is written per zone (16-bit, the sample rate is kept). Since the device plays a loop until the end of the sample, the data after the loop end is cut off. If any zone is stereo, all samples of the program are written as stereo pairs. The keymap covers MIDI notes 12-127 (C0-G9 in Kurzweil terms), keys below are dropped. Several multi-samples can be written into one file as a library; if the object IDs (200-999 per type) do not suffice, multiple files are created. +When writing, a file is created which uses only K2000 features and therefore loads on all three device families (the current Kurzweil range - K2700, PC4, Forte - imports these files as well). Each multi-sample becomes a program with one layer and a keymap; the velocity layers of the source are quantized onto the 8 dynamic levels of the keymap. One sample object is written per zone (16-bit; the sample rate is kept up to 96kHz, the maximum sample playback rate of the devices) with the zone gain in its volume adjust. Since the device plays a loop until the end of the sample, the data after the loop end is cut off. If any zone is stereo, all samples of the program are written as stereo pairs. The keymap covers MIDI notes 12-127 (C0-G9 in Kurzweil terms), keys below are dropped. Several multi-samples can be written into one file as a library; if the object IDs (200-999 per type) do not suffice, multiple files are created. #### Destination Options diff --git a/documentation/design/KURZWEIL_FORMAT.md b/documentation/design/KURZWEIL_FORMAT.md index b96ecbd0..9c96aa0b 100644 --- a/documentation/design/KURZWEIL_FORMAT.md +++ b/documentation/design/KURZWEIL_FORMAT.md @@ -68,8 +68,10 @@ numHeaders+1 x sample header (32 bytes each): uint8 flags 0x80 = loop OFF (inverted!), 0x40 = sample data present in this file (otherwise the header references device ROM), KurzFiler writes 0x70 for imported samples - int8 volumeAdjust (unit not confirmed, KurzFiler writes 0) - int8 altVolumeAdjust + int8 volumeAdjust volume adjust in 0.5 dB steps (-64.0 to +63.5 dB, the range of + the Volume Adjust parameter on the MISC page of the sample + editor in the K2600 manual) + int8 altVolumeAdjust volume adjust used when the alternative start is active int16 maxPitch highest playable transposition in cents: ceil(1200*log2(96000e-9*samplePeriod)) + 100*rootKey - 1200 int16 offsetToName @@ -157,8 +159,10 @@ and sets mode 2. ## Not interpreted / unknown -* The unit of the volume adjust fields (sample header and keymap entry) is not confirmed and - therefore ignored. +* The unit of the keymap entry volume adjust field is not confirmed and therefore ignored + (the sample header volume adjust is confirmed to be 0.5 dB steps, see above). * The natural envelope records and the program ENV/ASR/LFO/FUN segment contents beyond the - values above are not decoded. + values above are not decoded. The per-sample playback direction (Normal/Reverse/ + Bidirectional on the MISC page of the sample editor) is stored somewhere in the sample + header but the bit positions are unknown. * Multi-floppy spanning files (.KR1/.K21 etc. part files) are not supported. diff --git a/src/main/java/de/mossgrabers/convertwithmoss/format/kurzweil/KurzweilCreator.java b/src/main/java/de/mossgrabers/convertwithmoss/format/kurzweil/KurzweilCreator.java index 7cd5f00a..717375c6 100644 --- a/src/main/java/de/mossgrabers/convertwithmoss/format/kurzweil/KurzweilCreator.java +++ b/src/main/java/de/mossgrabers/convertwithmoss/format/kurzweil/KurzweilCreator.java @@ -37,10 +37,13 @@ */ public class KurzweilCreator extends AbstractCreator { + /** The maximum sample playback rate of the devices. */ + private static final int MAX_SAMPLE_RATE = 96000; + private static final DestinationAudioFormat DESTINATION_FORMAT = new DestinationAudioFormat (new int [] { 16 - }, -1, false); + }, MAX_SAMPLE_RATE, false); private static final int MAX_NAME_LENGTH = 16; @@ -157,6 +160,9 @@ private void addMultisample (final KurzweilFile kurzweilFile, final IMultisample { final String name = multisampleSource.getName (); + // Samples above the maximum playback rate of the devices are down-sampled + recalculateAllSamplePositions (multisampleSource, MAX_SAMPLE_RATE, true); + // Convert all zones first to know if the program needs to be stereo final List preparedZones = new ArrayList<> (); for (final IGroup group: multisampleSource.getNonEmptyGroups (true)) @@ -376,6 +382,7 @@ private static KurzweilSample createSample (final int sampleID, final String nam header.setSampleData (preparedZone.channelData[Math.min (channel, preparedZone.channelData.length - 1)]); header.setSampleRate (preparedZone.sampleRate); header.setRootKey (preparedZone.rootKey); + header.setVolumeAdjust (preparedZone.zone.getGain ()); if (preparedZone.isLooped) header.setLoopStart (preparedZone.loopStart); sample.addHeader (header); diff --git a/src/main/java/de/mossgrabers/convertwithmoss/format/kurzweil/KurzweilDetector.java b/src/main/java/de/mossgrabers/convertwithmoss/format/kurzweil/KurzweilDetector.java index 863df467..639dfc8a 100644 --- a/src/main/java/de/mossgrabers/convertwithmoss/format/kurzweil/KurzweilDetector.java +++ b/src/main/java/de/mossgrabers/convertwithmoss/format/kurzweil/KurzweilDetector.java @@ -301,6 +301,7 @@ private ISampleZone createZone (final KurzweilFile kurzweilFile, final KurzweilK zone.setSampleData (sampleDataCache.computeIfAbsent (Long.valueOf ((long) sample.getId () << 16 | headerIndex), _ -> createSampleData (header, rightHeader))); zone.setKeyRoot (header.getRootKey ()); zone.setTuning ((entry.getTuning () + layer.getTranspose () * 100) / 100.0); + zone.setGain (header.getVolumeAdjust ()); zone.setStart (0); zone.setStop (header.getNumberOfFrames ()); addLoop (zone, header); @@ -346,6 +347,7 @@ else if (reportedRomSampleIDs.add (Integer.valueOf (sample.getId ()))) final ISampleZone zone = new DefaultSampleZone (sample.getName (), keyLow, keyHigh); zone.setSampleData (sampleDataCache.computeIfAbsent (Long.valueOf ((long) sample.getId () << 16 | headerIndex), _ -> createSampleData (header, rightHeader))); zone.setKeyRoot (rootKey); + zone.setGain (header.getVolumeAdjust ()); zone.setStart (0); zone.setStop (header.getNumberOfFrames ()); addLoop (zone, header); diff --git a/src/main/java/de/mossgrabers/convertwithmoss/format/kurzweil/KurzweilSampleHeader.java b/src/main/java/de/mossgrabers/convertwithmoss/format/kurzweil/KurzweilSampleHeader.java index 3737db8d..2525eb50 100644 --- a/src/main/java/de/mossgrabers/convertwithmoss/format/kurzweil/KurzweilSampleHeader.java +++ b/src/main/java/de/mossgrabers/convertwithmoss/format/kurzweil/KurzweilSampleHeader.java @@ -217,6 +217,29 @@ public void setSampleRate (final int sampleRate) } + /** + * Get the volume adjustment of the sample. The file stores it in 0.5 dB steps (-64.0 to +63.5 + * dB, as documented on the MISC page of the sample editor). + * + * @return The volume adjustment in dB + */ + public double getVolumeAdjust () + { + return this.volumeAdjust / 2.0; + } + + + /** + * Set the volume adjustment of the sample. + * + * @param volumeAdjustDB The volume adjustment in dB, clamped to -64.0..+63.5 + */ + public void setVolumeAdjust (final double volumeAdjustDB) + { + this.volumeAdjust = (int) Math.clamp (Math.round (volumeAdjustDB * 2.0), -128, 127); + } + + /** * Get the root key. * From d26d3618c81dd8d515cff6245b38d631c99e64fb Mon Sep 17 00:00:00 2001 From: Douglas Carmichael Date: Sun, 19 Jul 2026 19:23:24 -0400 Subject: [PATCH 4/4] Kurzweil: adapt to the Optional sample-data API (rebase onto current main) --- .../convertwithmoss/format/kurzweil/KurzweilCreator.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/de/mossgrabers/convertwithmoss/format/kurzweil/KurzweilCreator.java b/src/main/java/de/mossgrabers/convertwithmoss/format/kurzweil/KurzweilCreator.java index 717375c6..b0e8142a 100644 --- a/src/main/java/de/mossgrabers/convertwithmoss/format/kurzweil/KurzweilCreator.java +++ b/src/main/java/de/mossgrabers/convertwithmoss/format/kurzweil/KurzweilCreator.java @@ -310,13 +310,13 @@ private static int fillEntries (final KurzweilKeymapEntry [] entries, final Prep */ private PreparedZone prepareZone (final ISampleZone zone) throws IOException { - if (zone.getSampleData () == null) + if (zone.getSampleData () == null || zone.getSampleData ().isEmpty ()) { this.notifier.logError (IDS_NOTIFY_ERR_MISSING_SAMPLE_DATA, zone.getName (), zone.getName ()); return null; } - final WaveFile waveFile = AudioFileUtils.convertToWav (zone.getSampleData (), DESTINATION_FORMAT); + final WaveFile waveFile = AudioFileUtils.convertToWav (zone.getSampleData ().get (), DESTINATION_FORMAT); final int numChannels = waveFile.getFormatChunk ().getNumberOfChannels (); if (numChannels > 2) {