From 725992e895297f67a14365f90179a1efad1ec985 Mon Sep 17 00:00:00 2001 From: Douglas Carmichael Date: Tue, 15 Sep 2026 12:56:57 -0400 Subject: [PATCH 1/3] Sequential Prophet X: read and write the sample instruments (GRP, ZIP) An instrument is a folder with its WAV files and a tab-separated group file; the device imports it as px///.zip from a USB drive. The format was reverse-engineered from the OS 2.2.2 firmware (an unstripped QNX ELF), see documentation/design/PROPHET_X_GRP_FORMAT.md. Read: key and velocity ranges, loops (inclusive end), round robin sets as groups, root key and tuning from the frequency, key tracking, the category from the folder, the name from the Instrument Name column and Volume.txt as the velocity response (depth and curve). Write: the import archive in the USB layout or a plain folder, samples as 16 bit / 48 kHz since the device ignores the sample rate of a file, play ranges cut into the audio, round robin numbers, Volume.txt when the velocity response is not the default, and a UUID derived from bank, category and name so that a re-conversion keeps the programs of the device valid. Not yet verified on hardware. --- documentation/CHANGELOG.md | 1 + documentation/README-FORMATS.md | 15 + .../SupportedFeaturesSampleFormats.fods | 214 +++++++ documentation/design/PROPHET_X_GRP_FORMAT.md | 166 +++++ .../core/ConverterBackend.java | 4 + .../sequential/prophetx/ProphetXCreator.java | 443 ++++++++++++++ .../prophetx/ProphetXCreatorUI.java | 204 +++++++ .../sequential/prophetx/ProphetXDetector.java | 575 ++++++++++++++++++ .../sequential/prophetx/ProphetXTag.java | 295 +++++++++ src/main/resources/Strings.properties | 16 + 10 files changed, 1933 insertions(+) create mode 100644 documentation/design/PROPHET_X_GRP_FORMAT.md create mode 100644 src/main/java/de/mossgrabers/convertwithmoss/format/sequential/prophetx/ProphetXCreator.java create mode 100644 src/main/java/de/mossgrabers/convertwithmoss/format/sequential/prophetx/ProphetXCreatorUI.java create mode 100644 src/main/java/de/mossgrabers/convertwithmoss/format/sequential/prophetx/ProphetXDetector.java create mode 100644 src/main/java/de/mossgrabers/convertwithmoss/format/sequential/prophetx/ProphetXTag.java diff --git a/documentation/CHANGELOG.md b/documentation/CHANGELOG.md index 8a01c449..03d307ff 100644 --- a/documentation/CHANGELOG.md +++ b/documentation/CHANGELOG.md @@ -5,6 +5,7 @@ * Many thanks to Douglas Carmichael for plenty of contributions and fixes! * New: Added support for the E-mu Emulator (1981), the first Emulator (HFE, EMUFD, IMG). Written disks are not yet verified on hardware. * New: Added support for the E-mu Emax and Emax II (EM1, EB1, EM2, EB2, EM1FD, EM2FD, EZ1, EZ2, IMG, ISO, HFE). Written banks are not yet verified on hardware. +* New: Added support for the sample instruments of the Sequential Prophet X / XL (GRP, ZIP): the instrument folders with their group file and the ZIP archives which the device imports from a USB drive. Written instruments are not yet verified on hardware. * New: If no category could be detected, another attempt is made by using all group and zone names. * Fixed: The main window had a minimum size of 1280x840 pixels and therefore did not fit onto smaller screens like the 1366x768 pixels of many laptops - it opened larger than the screen and could not be made smaller. The minimum and the initial size of the window are now limited to the visible area of the screen. * Fixed: 32-bit float samples (e.g. the WAV files which DirectWave writes) were always converted to 16 bit, even though the log announced the 24 bit of the destination format. A float sample is now converted to the highest resolution the destination supports - 24 bit for e.g. SoundFont 2, Bliss, Tonverk, DirectWave and FLAC, 32 bit for ALAC - and keeps its float format for destinations without a restriction, e.g. when only the chunks of a WAV file are updated. diff --git a/documentation/README-FORMATS.md b/documentation/README-FORMATS.md index deff8ef1..89d00147 100644 --- a/documentation/README-FORMATS.md +++ b/documentation/README-FORMATS.md @@ -91,6 +91,7 @@ The following multi-sample formats are supported: * [Roland SP-404MK2](#roland-sp-404mk2) * [Roland ZEN-Core](#roland-zen-core) * [Sample files (AIFF, CAF, FLAC, NCW, OGG, WAV)](#sample-files-aiff-caf-flac-ncw-ogg-wav) +* [Sequential Prophet X](#sequential-prophet-x) * [SFZ](#sfz) * [SoundFont 2](#soundfont-2) * [Spectrasonics Omnisphere 3](#spectrasonics-omnisphere-3) @@ -864,6 +865,20 @@ WAV file can contain different sample formats. This converter supports (split) s * Audio file format: The format of the written sample files: WAV, AIFF, CAF, CAF-ALAC (CAF compressed with Apple Lossless), CAF-AAC (lossy) or FLAC. +## Sequential Prophet X + +The Prophet X and Prophet XL are hybrid synthesizers from Sequential whose sample engine plays multi-sampled instruments. An instrument is a folder which contains its samples as WAV files and a *group file* (ending *.grp*): a tab-separated table with a header line and one line per sample - the file name, the key and velocity range, the loop, the number in a round robin set, the frequency at which the sample sounds and whether it tracks the keyboard. An optional *Volume.txt* next to it holds one gain factor per velocity. The device imports such instruments from a USB drive as ZIP archives which lie in the folder *px/<user bank>/<category>/*, e.g. *px/u00/15 Synth/Rhodes.zip*, which is the layout that the official mapping utility of 8Dio creates. Sequential does not document the format; it was reverse-engineered from the OS 2.2.2 firmware, see *documentation/design/PROPHET_X_GRP_FORMAT.md*. + +Reading takes both plain instrument folders (via their group file) and the import archives. Each line becomes a zone with its key range, velocity range, loop, root key and fine tuning (both from the frequency), key tracking (a *Mono Pitch* sample plays untransposed on every key) and its round robin number, which puts the samples of a set into groups that play in turn. The category comes from the category folder in which the instrument lies, or from the *Category* column if it lies in none, and the name from the *Instrument Name* column. The velocity volumes of *Volume.txt* are fitted to the amplitude velocity modulation (depth and curve); without the file the device uses its built-in curve, which is left to the defaults. + +Writing creates, by default, the import archive in the USB layout: copy the *px* folder onto a USB drive which was formatted by the device, insert it into the sample import port and import the user bank from the Global menu of the device. The category is the closest one of the 17 categories of the device to the category of the source, unless one is selected. The samples are written as 16 bit / 48 kHz WAV files: the device ignores the sample rate of a file and plays everything at 48 kHz, so any other rate would sound transposed. A zone with a play range is cut to it, since the format has no play range. Only the first loop of a zone is written and always as a forward loop; a backwards or alternating loop and reversed playback are reported. Round robin and random zones get their number in their set and the device picks one of them at random; zones which trigger on release are skipped. The velocity response is written to *Volume.txt* if all zones share one which differs from the default of full, linear velocity. The mapping utility of the device allows at most 128 samples per instrument, more are reported but written. The programs of the device address an instrument by a UUID, which is derived from the bank, the category and the name, so a source which is converted again gives the same instrument. When a Prophet X instrument is converted to a Prophet X instrument again, enable the flat output, otherwise the *px* folder of the source is recreated below the destination folder and the new *px* folder lands inside it. Written instruments are not yet verified on hardware. + +### Destination Options + +* Category: The category of the device in which to store the instrument. *From the source* takes the closest one to the category of the source. +* User bank: The user bank (u00 to u31) of the device in which to store the instrument. +* Write the import archive: If enabled, the instrument is written as *px/<bank>/<category>/<name>.zip*, ready to be imported from a USB drive. If disabled, a plain folder with the group file and the samples is written instead. + ## SFZ "The SFZ format is a file format to define how a collection of samples are arranged for performance. The goal behind the SFZ format is to provide a free, simple, minimalistic and expandable format to arrange, distribute and use audio samples with the highest possible quality and the highest possible performance flexibility" (cited from https://sfzformat.com/). diff --git a/documentation/SupportedFeaturesSampleFormats.fods b/documentation/SupportedFeaturesSampleFormats.fods index 30eaefe6..def586e5 100644 --- a/documentation/SupportedFeaturesSampleFormats.fods +++ b/documentation/SupportedFeaturesSampleFormats.fods @@ -9171,6 +9171,220 @@ + + + Sequential Prophet X + + + Read + + + grp (instrument folder), zip (import archive) + + + + 16 on the device, any is read + + + 48 kHz on the device, any is read + + + + + Instrument Name column, else the file name + + + category folder, else the Category column + + + + + + from filename + + + + + Round Robin N from the round robin number + + + + + + + + Pitch in Hertz (closest key) + + + Low / High Midi Note + + + + Low / High Velocity + + + + + + Pitch in Hertz (rest as cents) + + + Mono Pitch (on / off) + + + + + + Round Robin Number (round robin) + + + + + + + + Forward only + + + + Loop Start + + + Loop End (inclusive) + + + + + + + + + Volume.txt (fitted to depth and curve) + + + + + + + + + + + + + + + + + + + + + + + + + + + Write + + + zip in px/bank/category, or a folder + + + + 16 + + + 48 kHz + + + + + file, folder and Instrument Name column + + + category folder: option or closest to the source + + + + + + + + + round robin number + + + + + + + + Pitch in Hertz + + + + + + + cut to the play range + + + cut to the play range + + + Pitch in Hertz + + + Mono Pitch (below 50% = off) + + + + + + Round Robin Number (round robin, random) + + + + + + + + + + + + + + + + + + Volume.txt if all zones differ from the default + + + + + + + + + + + + + + + + + + + + + + + Soundfont 2 diff --git a/documentation/design/PROPHET_X_GRP_FORMAT.md b/documentation/design/PROPHET_X_GRP_FORMAT.md new file mode 100644 index 00000000..254863c4 --- /dev/null +++ b/documentation/design/PROPHET_X_GRP_FORMAT.md @@ -0,0 +1,166 @@ +# Sequential Prophet X / XL sample instrument (`.grp`) format — reverse-engineered + +Reverse-engineered 2026-09 from the **Prophet X OS 2.2.2.0.0 update image** (`PxUpdate_V2.2.2.0.0.bin`, +3 099 064 bytes, December 2021). The image is a 13-byte vendor header followed by an unstripped +x86-64 QNX 7 ELF executable, so all class, method and global names are available: the reader of the +instrument file is `GroupFileParse` with its tokenizer `CGroupVelocityManifestFileReader`, the column +names are the table `gGroupFileParmNames`, the WAV reader is `CWavReader::Open`/`Read`, the zone +lookup `CSampleGroup::FindHandler`, the USB importer `LibraryCopy_DoLibraryUpdate` and the pitch law +sits in `CSampleOsc::PlayNote`. Sequential does not document the format; the official "Prophet X +Series Mapping Utility" (8Dio) is the only public writer and states 16 bit / 48 kHz WAV, at most 128 +files and 1.5 GB per instrument. + +No factory content was available, so everything below comes from the code paths which read and +import the files. Statements about what the device *does* with a value are taken from the code; +the written output of ConvertWithMoss has **not** been verified on hardware yet. + +## What kind of instrument this is + +The Prophet X is a hybrid synthesizer whose two sample oscillators play **multi-sampled +instruments**: key and velocity zones with optional round robins, one WAV file per zone. Envelopes, +filters, LFOs, pitch bend, loop cross-fade and reverse/alternating loop playback are parameters of +the *program* which plays the instrument, not of the instrument file, so a `.grp` carries only the +mapping. + +**ConvertWithMoss mapping.** One instrument = one multi-sample source. Each line of the group file +is one zone; the lines which share a round robin number form one group ("Round Robin N") which the +model plays in turn, the lines without a number form the first group. The category comes from the +category folder of the instrument, the name from the `Instrument Name` column (the device itself +lists the name of the group file). Written instruments carry every zone with its key and velocity +range, loop, frequency (root key plus fine tuning) and round robin number, and the velocity response +of the zones as `Volume.txt` when it is not the default of the model. + +## Storage layout on the instrument + +Instruments live on the internal drive: + + /hd////.grp + /.wav ... + /Volume.txt (optional) + +* `` is one of 65 fixed folder names (`gpGroupNames`): `f00` (factory content), + `u00`..`u31` (user banks) and `p00`..`p31` (purchased expansion packs). +* `` is one of 17 fixed folders (`gpFullCategoryNames`): `01 Ambience`, `02 Bass`, + `03 Brass`, `04 Choir`, `05 Cinematic`, `06 Drums`, `07 Effects`, `08 Ethnic`, `09 Guitar`, + `10 Keyboard`, `11 Percussion`, `12 Tonal Perc`, `13 Piano`, `14 Strings`, `15 Synth`, `16 Vox`, + `17 Winds`. The indexer (`CSampleGroups_IndexerCallback`) skips the two digits and the space and + compares the rest with `gpCategoryNames`; any other folder is ignored. +* The instrument folder is found by walking a category folder (`DSI_FTW`, depth 4) for files whose + name ends with `.grp`. The file name without the ending is the name shown on the instrument; + names which start with a dot are skipped. Instruments are sorted with a numeric-prefix-aware + string compare (`firstnum_strcmp`). +* `/hd//GroupInfo.txt` only caches the size of the group (`Group Size: `); it is not + part of an instrument. + +The instrument dictionary (`CInstrumentDictionary::Find`) looks an instrument up by its 16-byte +UUID first and falls back to its name, which is how programs reference the instruments they play. +ConvertWithMoss therefore writes a UUID which is derived (version 3, name-based) from the user +bank, the category and the name, so converting a source again yields the same instrument. + +## Import from USB + +The importer (`LibraryCopy_DoLibraryUpdate`) walks + + /fs/usb0/px///.zip + +and installs every archive whose two parent folders are a valid group and category name +(`LibraryCopy_ValidateFolder`). Each archive is extracted into `/hd///` with +`unzip -qq -P asdf` (a second, obfuscated password is tried for the encrypted expansion packs; an +unencrypted archive is accepted since `-P` only applies to encrypted entries). After extraction +`/.grp` must exist, otherwise the folder is deleted again. So the archive +must contain the instrument folder itself, named like the archive, with the group file named like +the folder. The drive is formatted by the device (`mkdosfs -L ProphetX`, FAT32). + +## The group file + +A plain text table read by `CGroupVelocityManifestFileReader`: + +* Lines end with CR, LF or CR LF; empty lines are skipped. The line buffer holds 512 bytes. +* Values are separated by TAB. Only `\t`, `\n`, `\r` and NUL end a value, so values may contain + spaces; leading and consecutive separators are skipped, so an empty value shifts the following + ones to the left. +* The first line is the header which names the columns, in any order. The 15 known names are + compared with `strcmp` against `gGroupFileParmNames`; unknown columns are ignored. Every + following line describes one sample (`CWavHandler`). The file must not start with a byte order + mark, since that would corrupt the first column name. + +| Column | Type | Default | Meaning | +|----------------------|--------|---------|---------------------------------------------------------------------------------------------------------| +| `File Path` | text | | Appended to the instrument folder path; normally just the WAV file name. | +| `Low Midi Note` | int | 0 | First key of the zone, inclusive. | +| `High Midi Note` | int | 127 | Last key of the zone, inclusive. | +| `Low Velocity` | int | 0 | Lowest velocity, inclusive. | +| `High Velocity` | int | 127 | Highest velocity, inclusive. | +| `Loop Start` | int | -1 | First frame of the loop; -1 = none. | +| `Loop End` | int | -1 | Last frame of the loop, inclusive (see below); -1 = none. | +| `Round Robin Number` | int | -1 | 1-based number inside a set of samples which share key and velocity range; 0 is read as 1; -1 = no set. | +| `Pitch in Hertz` | float | 440 | Frequency at which the file sounds. | +| `Mono Pitch` | Y/N | N | `Y`: the sample plays at its own pitch on every key (no key tracking). | +| `Stereo/Mono File` | S/M | S | `S` = stereo file, anything else = mono. Informational; the channel count comes from the WAV. | +| `Category` | text | | Category name. Stored per instrument (first line wins); the folder decides the real category. | +| `Instrument Name` | text | | Display name. Stored per instrument (first line wins); the device lists the name of the group file. | +| `Mono Collapse` | L/R/B | B | Channel used when a stereo sample is collapsed to mono: left, right or both. | +| `UUID` | hex | zeros | 32 hex digits without separators (`StrToGUID` reads exactly 32 nibbles, no dashes). | + +Integers are read with `atoi` and the frequency with `atof`. The category and instrument name +buffers hold 64 bytes, so a name is limited to 63 characters; the name is also passed through a +shell (`unzip` is run with `system`), which is why the characters a shell expands are avoided. + +After reading, the samples are kept in a list sorted by low note, then low velocity, then round +robin number. Consecutive entries with identical low note and low velocity form the round robin set +of a zone; the velocity layers of a key zone are counted from its distinct low velocities. + +### Zone lookup (`CSampleGroup::FindHandler`) + +For a played note the zone with the smallest distance to `[Low Midi Note, High Midi Note]` is used, +so gaps in the key map are filled by the nearest zone. Among the candidates of that key zone the one +closest to `[Low Velocity, High Velocity]` wins. If the winner has a round robin number other than +-1, all following entries with the same low note and low velocity are counted and one of them is +chosen at random (`rand () % count`), or by an index which the caller passes. + +### Pitch + +`CSampleOsc::PlayNote` computes the playback rate as `frequency (note) / Pitch in Hertz`. The +sample rate of the WAV file is never read (`CWavReader::GetSampleRate` has no caller), so files +must be at the 48 kHz of the audio engine to sound at the intended pitch. A fractional root pitch +is expressed directly in the frequency: + + Pitch in Hertz = 440 * 2 ^ ((root - 69 - tuning) / 12) + +for a root key and a tuning offset in semitones (positive = the sample is played sharper), and +reading inverts this: the root key is the closest key to the frequency and the rest is the tuning. + +With `Mono Pitch = Y` the disk scheduler is opened with the truncated integer frequency and the +sample plays untransposed on every key. + +### Loops + +`Loop Start` / `Loop End` are handed unchanged to `CWavReader::Open`; a negative start or end +disables the loop, a start beyond the end or an end beyond the frame count rejects it and the file +plays without a loop. `CWavReader::Read` keeps reading while the frame position is <= `Loop End` +and wraps to `Loop Start` when it exceeds it, so both values are inclusive frame indices. A missing +start loops from the first frame, a missing end to the last one. Loop cross-fade and +forward/backward playback are voice parameters of the program, not part of the file. + +## `Volume.txt` + +Optional file next to the group file (`VelocityVolumeFileParse`): up to 128 decimal numbers, one +gain factor per MIDI velocity (index 0 = velocity 0), separated by anything which is not a digit or +a dot - the parser reads digits and `.` only, so neither a sign nor an exponent is possible. +Entries after the last number stay at 1.0. + +Without the file the instrument uses the built-in table `cVELOCITY_TO_VOLUME_TABLE` (128 floats): +0.126 (-18 dB) at velocity 0, 0.251 at 32, 0.504 at 64, 0.756 at 96 and 1.0 at 127, roughly a +linear velocity with an 18 dB floor. The file *replaces* this table, therefore it is the velocity +response of the instrument. ConvertWithMoss fits it to the amplitude velocity modulator of the +model, whose response is `1 - depth + depth * (velocity / 127) ^ (3 ^ curve)`: the depth is the +swing from velocity 0 to 127 and the curve the power law which fits the shape in between. It writes +the file from that modulator only when all zones share a response which differs from the default of +the model (depth 1, linear), so that an unspecified source keeps the built-in table of the device. + +## WAV files (`CWavReader::Open`) + +* `RIFF`/`WAVE` container; the chunks are scanned for `fmt ` and `data`, other chunks are skipped. +* 16-bit PCM only (`Only support 16-bit mono or stereo sample files`), one or two channels. +* Any sample rate is accepted but ignored, see above: use 48 kHz. +* The frame count is `data size / 2 / channels`. diff --git a/src/main/java/de/mossgrabers/convertwithmoss/core/ConverterBackend.java b/src/main/java/de/mossgrabers/convertwithmoss/core/ConverterBackend.java index 1afec7b5..53b21140 100644 --- a/src/main/java/de/mossgrabers/convertwithmoss/core/ConverterBackend.java +++ b/src/main/java/de/mossgrabers/convertwithmoss/core/ConverterBackend.java @@ -110,6 +110,8 @@ import de.mossgrabers.convertwithmoss.format.roland.zencore.ZenCoreCreator; import de.mossgrabers.convertwithmoss.format.roland.zencore.ZenCoreDetector; import de.mossgrabers.convertwithmoss.format.samplefile.SampleFileDetector; +import de.mossgrabers.convertwithmoss.format.sequential.prophetx.ProphetXCreator; +import de.mossgrabers.convertwithmoss.format.sequential.prophetx.ProphetXDetector; import de.mossgrabers.convertwithmoss.format.sf2.Sf2Creator; import de.mossgrabers.convertwithmoss.format.sf2.Sf2Detector; import de.mossgrabers.convertwithmoss.format.sfz.SfzCreator; @@ -248,6 +250,7 @@ public ConverterBackend (final INotifier notifier) this.detectors.add (new ZenCoreDetector (notifier)); this.detectors.add (new SxtDetector (notifier)); this.detectors.add (new SampleFileDetector (notifier)); + this.detectors.add (new ProphetXDetector (notifier)); this.detectors.add (new SfzDetector (notifier)); this.detectors.add (new Sf2Detector (notifier)); this.detectors.add (new OmnisphereDetector (notifier)); @@ -296,6 +299,7 @@ public ConverterBackend (final INotifier notifier) this.creators.add (new ZenCoreCreator (notifier)); this.creators.add (new SxtCreator (notifier)); this.creators.add (new WavCreator (notifier)); + this.creators.add (new ProphetXCreator (notifier)); this.creators.add (new SfzCreator (notifier)); this.creators.add (new Sf2Creator (notifier)); this.creators.add (new OmnisphereCreator (notifier)); diff --git a/src/main/java/de/mossgrabers/convertwithmoss/format/sequential/prophetx/ProphetXCreator.java b/src/main/java/de/mossgrabers/convertwithmoss/format/sequential/prophetx/ProphetXCreator.java new file mode 100644 index 00000000..b97c9ac9 --- /dev/null +++ b/src/main/java/de/mossgrabers/convertwithmoss/format/sequential/prophetx/ProphetXCreator.java @@ -0,0 +1,443 @@ +// 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.sequential.prophetx; + +import java.io.BufferedOutputStream; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.zip.ZipOutputStream; + +import de.mossgrabers.convertwithmoss.core.IMultisampleSource; +import de.mossgrabers.convertwithmoss.core.INotifier; +import de.mossgrabers.convertwithmoss.core.SafeFileNames; +import de.mossgrabers.convertwithmoss.core.creator.AbstractWavCreator; +import de.mossgrabers.convertwithmoss.core.creator.DestinationAudioFormat; +import de.mossgrabers.convertwithmoss.core.model.IAudioMetadata; +import de.mossgrabers.convertwithmoss.core.model.IGroup; +import de.mossgrabers.convertwithmoss.core.model.IModulator; +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.enumeration.PlayLogic; +import de.mossgrabers.convertwithmoss.core.model.enumeration.TriggerType; +import de.mossgrabers.tools.FileUtils; + + +/** + * Creator for Sequential Prophet X / XL sample instruments. An instrument is a folder which + * contains its samples as WAV files and a group file (ending .grp) with the key, velocity + * and loop mapping. By default the instrument is written as the archive which the device imports + * from a USB drive: px/<bank>/<category>/<name>.zip. The format was + * reverse-engineered from the OS 2.2.2 firmware, see + * documentation/design/PROPHET_X_GRP_FORMAT.md. + * + * @author Jürgen Moßgraber + */ +public class ProphetXCreator extends AbstractWavCreator +{ + /** + * The device plays 16 bit and ignores the sample rate of a file: a sample at another rate than + * the 48 kHz of its audio engine plays transposed, therefore every sample is converted. + */ + private static final DestinationAudioFormat DESTINATION_FORMAT = new DestinationAudioFormat (new int [] + { + ProphetXTag.BIT_RESOLUTION + }, ProphetXTag.SAMPLE_RATE, true); + + private static final String WAV_ENDING = ".wav"; + private static final String LINE_ENDING = "\r\n"; + private static final String FALLBACK_NAME = "Instrument"; + private static final int DEFAULT_ROOT_KEY = 60; + private static final int NUM_VELOCITIES = 128; + + + /** + * Constructor. + * + * @param notifier The notifier + */ + public ProphetXCreator (final INotifier notifier) + { + super ("Sequential Prophet X", "ProphetX", notifier, new ProphetXCreatorUI ("ProphetX")); + } + + + /** {@inheritDoc} */ + @Override + public void createPreset (final File destinationFolder, final IMultisampleSource multisampleSource) throws IOException + { + final List zones = this.collectZones (multisampleSource); + if (zones.isEmpty ()) + { + this.notifier.logError (IDS_NOTIFY_ERR_MISSING_SAMPLE_DATA, multisampleSource.getName (), "-"); + return; + } + if (zones.size () > ProphetXTag.MAX_SAMPLES) + this.notifier.log ("IDS_PROPHETX_TOO_MANY_SAMPLES", Integer.toString (zones.size ()), Integer.toString (ProphetXTag.MAX_SAMPLES)); + this.checkLimitations (zones); + + final int category = this.getCategory (multisampleSource); + final String bank = ProphetXTag.getUserBank (this.settingsConfiguration.getBankIndex ()); + + // The device ignores the sample rate of a WAV file and plays every sample at 48 kHz, so all + // samples are converted; the audio of a loop and its positions are converted together + this.recalculateSamplePositions (multisampleSource, ProphetXTag.SAMPLE_RATE); + + if (this.settingsConfiguration.isWriteArchive ()) + this.writeArchive (destinationFolder, multisampleSource, zones, category, bank); + else + this.writeFolder (destinationFolder, multisampleSource, zones, category, bank); + + this.progress.notifyDone (); + } + + + /** + * Write the instrument as the archive which the device imports from a USB drive: + * px/<bank>/<category>/<name>.zip. The archive contains the + * instrument folder, which the importer of the device extracts next to the other instruments + * of the category. + * + * @param destinationFolder The folder in which to create the USB layout + * @param multisampleSource The multi-sample + * @param zones The zones to write + * @param category The index of the category of the device + * @param bank The folder name of the user bank + * @throws IOException Could not write the archive + */ + private void writeArchive (final File destinationFolder, final IMultisampleSource multisampleSource, final List zones, final int category, final String bank) throws IOException + { + final File categoryFolder = new File (new File (new File (destinationFolder, ProphetXTag.USB_FOLDER), bank), ProphetXTag.CATEGORY_FOLDERS[category]); + safeCreateDirectory (categoryFolder); + + final File archiveFile = this.createUniqueFilename (categoryFolder, createInstrumentName (multisampleSource), "zip"); + // The importer expects the folder and the group file inside the archive to carry the name + // of the archive, and the device lists the instrument under that name + final String name = FileUtils.getNameWithoutType (archiveFile); + this.notifier.log ("IDS_NOTIFY_STORING", archiveFile.getAbsolutePath ()); + + try (final ZipOutputStream zipOutputStream = new ZipOutputStream (new BufferedOutputStream (new FileOutputStream (archiveFile)))) + { + final Set writtenFiles = new HashSet<> (); + for (int zoneIndex = 0; zoneIndex < zones.size (); zoneIndex++) + { + if (this.isCancelled ()) + return; + + final ISampleZone zone = zones.get (zoneIndex); + // Zones which share a sample reference the same file + final String fileName = this.createSampleFilename (zone, zoneIndex, WAV_ENDING); + if (!writtenFiles.add (fileName)) + continue; + + this.progress.notifyProgress (); + final ByteArrayOutputStream outputStream = new ByteArrayOutputStream (); + // The format has no play range, therefore the sample is cut to it + this.rewriteFile (multisampleSource, zone, outputStream, DESTINATION_FORMAT, true); + zipDataFile (zipOutputStream, name + "/" + fileName, outputStream.toByteArray ()); + } + + // Cutting the samples moved their loops, so the group file is created afterwards + zipTextFile (zipOutputStream, name + "/" + name + ProphetXTag.GROUP_FILE_ENDING, this.createGroupFile (zones, name, category, bank)); + final Optional velocityVolumes = createVelocityVolumes (zones); + if (velocityVolumes.isPresent ()) + zipTextFile (zipOutputStream, name + "/" + ProphetXTag.VOLUME_FILE, velocityVolumes.get ()); + } + } + + + /** + * Write the instrument as a plain folder with its group file and samples. + * + * @param destinationFolder The folder in which to create the instrument folder + * @param multisampleSource The multi-sample + * @param zones The zones to write + * @param category The index of the category of the device + * @param bank The folder name of the user bank + * @throws IOException Could not write the files + */ + private void writeFolder (final File destinationFolder, final IMultisampleSource multisampleSource, final List zones, final int category, final String bank) throws IOException + { + final File instrumentFolder = this.createUniqueFilename (destinationFolder, createInstrumentName (multisampleSource), ""); + if (!instrumentFolder.mkdirs ()) + { + this.notifier.logError ("IDS_NOTIFY_FOLDER_COULD_NOT_BE_CREATED", instrumentFolder.getAbsolutePath ()); + return; + } + + // The device lists the instrument under the name of its group file, which is the name of + // its folder + final String name = instrumentFolder.getName (); + final File groupFile = new File (instrumentFolder, name + ProphetXTag.GROUP_FILE_ENDING); + this.notifier.log ("IDS_NOTIFY_STORING", groupFile.getAbsolutePath ()); + + // The format has no play range, therefore the samples are cut to it + this.writeSamples (instrumentFolder, multisampleSource, zones, WAV_ENDING, DESTINATION_FORMAT, true); + + // Cutting the samples moved their loops, so the group file is created afterwards + Files.write (groupFile.toPath (), this.createGroupFile (zones, name, category, bank).getBytes (StandardCharsets.UTF_8)); + final Optional velocityVolumes = createVelocityVolumes (zones); + if (velocityVolumes.isPresent ()) + Files.write (new File (instrumentFolder, ProphetXTag.VOLUME_FILE).toPath (), velocityVolumes.get ().getBytes (StandardCharsets.UTF_8)); + } + + + /** + * Collect the zones which the device can play: those with a mono or stereo sample which play + * on note-on. + * + * @param multisampleSource The multi-sample + * @return The zones + * @throws IOException Could not read the metadata of a sample + */ + private List collectZones (final IMultisampleSource multisampleSource) throws IOException + { + final List zones = new ArrayList<> (); + for (final IGroup group: multisampleSource.getNonEmptyGroups (true)) + for (final ISampleZone zone: group.getSampleZones ()) + { + final Optional sampleData = zone.getSampleData (); + if (sampleData.isEmpty () || zone.getTrigger () == TriggerType.RELEASE) + continue; + final int channels = sampleData.get ().getAudioMetadata ().getChannels (); + if (channels > 2) + { + this.notifier.logError ("IDS_PROPHETX_UNSUPPORTED_CHANNELS", zone.getName (), Integer.toString (channels)); + continue; + } + zones.add (zone); + } + return zones; + } + + + /** + * Report the settings of the zones which the device cannot play. + * + * @param zones The zones to write + */ + private void checkLimitations (final List zones) + { + int otherLoopTypes = 0; + int reversed = 0; + for (final ISampleZone zone: zones) + { + final List loops = zone.getLoops (); + if (!loops.isEmpty () && loops.get (0).getType () != LoopType.FORWARDS) + otherLoopTypes++; + if (zone.isReversed ()) + reversed++; + } + if (otherLoopTypes > 0) + this.notifier.log ("IDS_PROPHETX_LOOP_TYPE", Integer.toString (otherLoopTypes)); + if (reversed > 0) + this.notifier.log ("IDS_PROPHETX_REVERSED", Integer.toString (reversed)); + } + + + /** + * Get the category of the device in which to store the instrument: the selected one or the + * closest one to the category of the source. + * + * @param multisampleSource The multi-sample + * @return The index of the category + */ + private int getCategory (final IMultisampleSource multisampleSource) + { + final int selected = this.settingsConfiguration.getCategoryIndex (); + return selected >= 0 ? selected : ProphetXTag.fromModelCategory (multisampleSource.getMetadata ().getCategory ()); + } + + + /** + * Create the name of the instrument from the name of the multi-sample. The firmware copies the + * name into a buffer of 64 bytes, skips names which start with a dot and passes the name + * through a shell, so the characters which a shell expands are replaced. + * + * @param multisampleSource The multi-sample + * @return The name + */ + private static String createInstrumentName (final IMultisampleSource multisampleSource) + { + String name = SafeFileNames.create (multisampleSource.getName ()).strip (); + while (name.startsWith (".") || name.endsWith (".")) + name = (name.startsWith (".") ? name.substring (1) : name.substring (0, name.length () - 1)).strip (); + if (name.length () > ProphetXTag.MAX_NAME_LENGTH) + name = name.substring (0, ProphetXTag.MAX_NAME_LENGTH).strip (); + return name.isEmpty () ? FALLBACK_NAME : name; + } + + + /** + * Create the text of the group file: a header line with the column names and one line per + * zone, separated by tabs. + * + * @param zones The zones to write + * @param name The name of the instrument + * @param category The index of the category of the device + * @param bank The folder name of the user bank + * @return The text + * @throws IOException Could not read the metadata of a sample + */ + private String createGroupFile (final List zones, final String name, final int category, final String bank) throws IOException + { + final String categoryName = ProphetXTag.CATEGORY_NAMES[category]; + // The programs of the device address an instrument by its UUID and fall back to its name, + // therefore the UUID is derived from the name and the location of the instrument: a source + // which is converted again gives the same instrument + final String uuid = UUID.nameUUIDFromBytes ((bank + "/" + categoryName + "/" + name).getBytes (StandardCharsets.UTF_8)).toString ().replace ("-", ""); + + final List rows = new ArrayList<> (); + for (int zoneIndex = 0; zoneIndex < zones.size (); zoneIndex++) + rows.add (this.createRow (zones.get (zoneIndex), zoneIndex, categoryName, name, uuid)); + // The device sorts the rows the same way, this only keeps the file readable + rows.sort (Comparator.comparingInt ((final String [] row) -> Integer.parseInt (row[1])).thenComparingInt (row -> Integer.parseInt (row[3])).thenComparingInt (row -> Integer.parseInt (row[7]))); + + final StringBuilder text = new StringBuilder (); + text.append (String.join ("\t", ProphetXTag.COLUMNS)).append (LINE_ENDING); + for (final String [] row: rows) + text.append (String.join ("\t", row)).append (LINE_ENDING); + return text.toString (); + } + + + /** + * Create the values of one row of the group file from a zone. + * + * @param zone The zone + * @param zoneIndex The index of the zone + * @param categoryName The name of the category of the device + * @param name The name of the instrument + * @param uuid The unique identifier of the instrument + * @return The values in the order of the columns + * @throws IOException Could not read the metadata of the sample + */ + private String [] createRow (final ISampleZone zone, final int zoneIndex, final String categoryName, final String name, final String uuid) throws IOException + { + final IAudioMetadata audioMetadata = zone.getSampleData ().get ().getAudioMetadata (); + + final int keyLow = Math.clamp (zone.getKeyLow (), 0, 127); + final int keyHigh = Math.clamp (limitToDefault (zone.getKeyHigh (), 127), keyLow, 127); + final int velocityLow = Math.clamp (zone.getVelocityLow (), 0, 127); + final int velocityHigh = Math.clamp (limitToDefault (zone.getVelocityHigh (), 127), velocityLow, 127); + + // The device plays a key at the ratio of the frequency of the key and the frequency at + // which the file sounds, so the tuning is folded into that frequency + final int root = Math.clamp (limitToDefault (zone.getKeyRoot (), DEFAULT_ROOT_KEY), 0, 127); + final double pitchInHertz = ProphetXTag.DEFAULT_PITCH * Math.pow (2, (root - zone.getTuning () - 69) / 12.0); + + // The device plays both frames of a loop and rejects a loop which reaches beyond the file. + // The zone is cut to its play range when its sample is written, which moves the loop to + // the start of the cut part, so the length of the cut part is the limit + int loopStart = ProphetXTag.NOT_SET; + int loopEnd = ProphetXTag.NOT_SET; + final List loops = zone.getLoops (); + if (!loops.isEmpty ()) + { + final ISampleLoop loop = loops.get (0); + final int lastFrame = Math.max (getPlayedFrames (zone, audioMetadata) - 1, 0); + final int start = Math.clamp (loop.getStart (), 0, lastFrame); + final int end = loop.getEnd () < 0 ? lastFrame : Math.min (loop.getEnd (), lastFrame); + if (start < end) + { + loopStart = start; + loopEnd = end; + } + } + + // The samples of a round robin set share their key and velocity range and carry a number; + // the device picks one of them at random, so a random selection needs no other number + final int roundRobin = zone.getPlayLogic () == PlayLogic.ALWAYS ? ProphetXTag.NOT_SET : Math.max (zone.getSequencePosition (), 1); + + return new String [] + { + this.createSampleFilename (zone, zoneIndex, WAV_ENDING), + Integer.toString (keyLow), + Integer.toString (keyHigh), + Integer.toString (velocityLow), + Integer.toString (velocityHigh), + Integer.toString (loopStart), + Integer.toString (loopEnd), + Integer.toString (roundRobin), + formatDouble (pitchInHertz, 4), + // The device either tracks the keyboard fully or not at all + zone.getKeyTracking () < 0.5 ? ProphetXTag.YES : ProphetXTag.NO, + audioMetadata.getChannels () > 1 ? ProphetXTag.STEREO : ProphetXTag.MONO, + categoryName, + name, + ProphetXTag.COLLAPSE_BOTH, + uuid + }; + } + + + /** + * Get the number of frames which the written sample has: the play range of the zone if one is + * set, otherwise the whole sample. + * + * @param zone The zone + * @param audioMetadata The metadata of the sample of the zone + * @return The number of frames + */ + private static int getPlayedFrames (final ISampleZone zone, final IAudioMetadata audioMetadata) + { + final int frames = audioMetadata.getNumberOfSamples (); + final int start = Math.clamp (limitToDefault (zone.getStart (), 0), 0, frames); + final int stop = Math.clamp (limitToDefault (zone.getStop (), frames), start, frames); + return stop - start; + } + + + /** + * Create the optional file with the velocity volumes, one gain factor per velocity, from the + * amplitude velocity modulators of the zones. The device has a built-in curve which the file + * replaces, therefore it is only written if all zones share a response which differs from the + * default of the model. + * + * @param zones The zones to write + * @return The text of the file or empty if the built-in curve of the device is kept + */ + private static Optional createVelocityVolumes (final List zones) + { + Double depth = null; + Double curve = null; + for (final ISampleZone zone: zones) + { + final IModulator modulator = zone.getAmplitudeVelocityModulator (); + if (depth == null) + { + depth = Double.valueOf (modulator.getDepth ()); + curve = Double.valueOf (modulator.getCurve ()); + } + else if (depth.doubleValue () != modulator.getDepth () || curve.doubleValue () != modulator.getCurve ()) + return Optional.empty (); + } + if (depth == null || depth.doubleValue () == 1 && curve.doubleValue () == 0) + return Optional.empty (); + + // The response of the model is 1 - depth + depth * (velocity / 127) ^ (3 ^ curve) + final double power = Math.pow (3, curve.doubleValue ()); + final StringBuilder text = new StringBuilder (); + for (int velocity = 0; velocity < NUM_VELOCITIES; velocity++) + { + final double volume = 1 - depth.doubleValue () + depth.doubleValue () * Math.pow (velocity / (double) (NUM_VELOCITIES - 1), power); + // The firmware reads neither a sign nor an exponent + text.append (formatDouble (Math.max (volume, 0), 4)).append (LINE_ENDING); + } + return Optional.of (text.toString ()); + } +} diff --git a/src/main/java/de/mossgrabers/convertwithmoss/format/sequential/prophetx/ProphetXCreatorUI.java b/src/main/java/de/mossgrabers/convertwithmoss/format/sequential/prophetx/ProphetXCreatorUI.java new file mode 100644 index 00000000..6b5606d0 --- /dev/null +++ b/src/main/java/de/mossgrabers/convertwithmoss/format/sequential/prophetx/ProphetXCreatorUI.java @@ -0,0 +1,204 @@ +// 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.sequential.prophetx; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +import de.mossgrabers.convertwithmoss.core.INotifier; +import de.mossgrabers.convertwithmoss.core.settings.ICoreTaskSettings; +import de.mossgrabers.convertwithmoss.core.settings.WavChunkSettingsUI; +import de.mossgrabers.tools.ui.BasicConfig; +import de.mossgrabers.tools.ui.Functions; +import de.mossgrabers.tools.ui.control.TitledSeparator; +import de.mossgrabers.tools.ui.panel.BoxPanel; +import javafx.geometry.Orientation; +import javafx.scene.control.CheckBox; +import javafx.scene.control.ComboBox; +import javafx.scene.layout.Pane; + + +/** + * Settings for the Sequential Prophet X creator: the category and the user bank of the device in + * which to store the instrument, whether to write the import archive or a plain folder, plus the + * shared WAV chunk options. + * + * @author Jürgen Moßgraber + */ +public class ProphetXCreatorUI extends WavChunkSettingsUI +{ + private static final String CATEGORY = "Category"; + private static final String BANK = "Bank"; + private static final String WRITE_ARCHIVE = "Archive"; + + private ComboBox categoryBox; + private ComboBox bankBox; + private CheckBox writeArchiveBox; + private int categoryIndex = -1; + private int bankIndex = 0; + private boolean writeArchive = true; + + + /** + * Constructor. + * + * @param prefix The prefix to use for the identifier + */ + public ProphetXCreatorUI (final String prefix) + { + super (prefix); + } + + + /** {@inheritDoc} */ + @Override + public Pane getEditPane () + { + final BoxPanel panel = new BoxPanel (Orientation.VERTICAL); + + panel.createSeparator ("@IDS_PROPHETX_SEPARATOR"); + + final List categories = new ArrayList<> (); + categories.add (Functions.getMessage ("IDS_PROPHETX_CATEGORY_FROM_SOURCE")); + categories.addAll (Arrays.asList (ProphetXTag.CATEGORY_FOLDERS)); + this.categoryBox = panel.createComboBox ("@IDS_PROPHETX_CATEGORY", categories); + + final List banks = new ArrayList<> (); + for (int i = 0; i < ProphetXTag.NUM_USER_BANKS; i++) + banks.add (ProphetXTag.getUserBank (i)); + this.bankBox = panel.createComboBox ("@IDS_PROPHETX_BANK", banks); + + this.writeArchiveBox = panel.createCheckBox ("@IDS_PROPHETX_WRITE_ARCHIVE"); + + final TitledSeparator separator = this.addWavChunkOptions (panel); + separator.getStyleClass ().add ("titled-separator-pane"); + return panel.getPane (); + } + + + /** {@inheritDoc} */ + @Override + public void loadSettings (final BasicConfig config) + { + this.categoryBox.getSelectionModel ().select (Math.clamp (config.getInteger (this.prefix + CATEGORY, -1) + 1, 0, ProphetXTag.CATEGORY_FOLDERS.length)); + this.bankBox.getSelectionModel ().select (Math.clamp (config.getInteger (this.prefix + BANK, 0), 0, ProphetXTag.NUM_USER_BANKS - 1)); + this.writeArchiveBox.setSelected (config.getBoolean (this.prefix + WRITE_ARCHIVE, true)); + + super.loadSettings (config); + } + + + /** {@inheritDoc} */ + @Override + public void saveSettings (final BasicConfig config) + { + config.setInteger (this.prefix + CATEGORY, this.categoryBox.getSelectionModel ().getSelectedIndex () - 1); + config.setInteger (this.prefix + BANK, this.bankBox.getSelectionModel ().getSelectedIndex ()); + config.setBoolean (this.prefix + WRITE_ARCHIVE, this.writeArchiveBox.isSelected ()); + + super.saveSettings (config); + } + + + /** {@inheritDoc} */ + @Override + public boolean checkSettingsUI (final INotifier notifier) + { + if (!super.checkSettingsUI (notifier)) + return false; + + this.categoryIndex = this.categoryBox.getSelectionModel ().getSelectedIndex () - 1; + this.bankIndex = Math.max (this.bankBox.getSelectionModel ().getSelectedIndex (), 0); + this.writeArchive = this.writeArchiveBox.isSelected (); + return true; + } + + + /** {@inheritDoc} */ + @Override + public boolean checkSettingsCLI (final INotifier notifier, final Map parameters) + { + if (!super.checkSettingsCLI (notifier, parameters)) + return false; + + final String category = parameters.remove (this.prefix + CATEGORY); + if (category == null || category.isBlank ()) + this.categoryIndex = -1; + else + { + this.categoryIndex = ProphetXTag.getCategoryIndex (category); + if (this.categoryIndex < 0) + { + notifier.logError ("IDS_PROPHETX_UNKNOWN_CATEGORY", category); + return false; + } + } + + final String bank = parameters.remove (this.prefix + BANK); + if (bank == null || bank.isBlank ()) + this.bankIndex = 0; + else + { + this.bankIndex = ProphetXTag.getUserBankIndex (bank); + if (this.bankIndex < 0) + { + notifier.logError ("IDS_PROPHETX_UNKNOWN_BANK", bank); + return false; + } + } + + this.writeArchive = ICoreTaskSettings.parseBoolean (parameters.remove (this.prefix + WRITE_ARCHIVE), true); + return true; + } + + + /** {@inheritDoc} */ + @Override + public String [] getCLIParameterNames () + { + final List parameterNames = new ArrayList<> (Arrays.asList (super.getCLIParameterNames ())); + parameterNames.add (this.prefix + CATEGORY); + parameterNames.add (this.prefix + BANK); + parameterNames.add (this.prefix + WRITE_ARCHIVE); + return parameterNames.toArray (new String [parameterNames.size ()]); + } + + + /** + * Get the selected category of the device. + * + * @return The index of the category or -1 to take the closest one to the category of the + * source + */ + public int getCategoryIndex () + { + return this.categoryIndex; + } + + + /** + * Get the selected user bank of the device. + * + * @return The index of the bank, 0 to {@link ProphetXTag#NUM_USER_BANKS} - 1 + */ + public int getBankIndex () + { + return this.bankIndex; + } + + + /** + * Should the instrument be written as the import archive in the folder layout of the USB + * drive instead of a plain folder? + * + * @return True to write the archive + */ + public boolean isWriteArchive () + { + return this.writeArchive; + } +} diff --git a/src/main/java/de/mossgrabers/convertwithmoss/format/sequential/prophetx/ProphetXDetector.java b/src/main/java/de/mossgrabers/convertwithmoss/format/sequential/prophetx/ProphetXDetector.java new file mode 100644 index 00000000..33e38cb8 --- /dev/null +++ b/src/main/java/de/mossgrabers/convertwithmoss/format/sequential/prophetx/ProphetXDetector.java @@ -0,0 +1,575 @@ +// 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.sequential.prophetx; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Enumeration; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.TreeMap; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; + +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.IModulator; +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.enumeration.PlayLogic; +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.settings.MetadataSettingsUI; +import de.mossgrabers.tools.FileUtils; + + +/** + * Detects Sequential Prophet X / XL sample instruments. An instrument is a folder which contains + * its samples as WAV files and a group file (ending .grp) with the key, velocity and loop + * mapping. The archives (ending .zip) which the device imports from a USB drive hold such a + * folder and are read as well. The format was reverse-engineered from the OS 2.2.2 firmware, see + * documentation/design/PROPHET_X_GRP_FORMAT.md. + * + * @author Jürgen Moßgraber + */ +public class ProphetXDetector extends AbstractDetector +{ + /** Reads the files of an instrument folder, which is either a plain folder or an archive. */ + private interface IInstrumentFolder + { + /** + * Load the data of a sample. + * + * @param filePath The file path from the group file, relative to the instrument folder + * @return The sample data or null if the sample was not found, which is already reported + * @throws IOException Could not read the sample + */ + ISampleData loadSample (String filePath) throws IOException; + + + /** + * Load the optional file with the velocity volumes. + * + * @return The text of the file or null if the instrument has none + * @throws IOException Could not read the file + */ + String loadVelocityVolumes () throws IOException; + } + + + private static final String MACOS_RESOURCE_FOLDER = "__MACOSX/"; + /** The firmware reads digits and dots only. */ + private static final Pattern VOLUME_NUMBER = Pattern.compile ("[0-9]+(\\.[0-9]*)?|\\.[0-9]+"); + private static final int NUM_VELOCITIES = 128; + + + /** + * Constructor. + * + * @param notifier The notifier + */ + public ProphetXDetector (final INotifier notifier) + { + super ("Sequential Prophet X", "ProphetX", notifier, new MetadataSettingsUI ("ProphetX"), ProphetXTag.GROUP_FILE_ENDING, ProphetXTag.ARCHIVE_ENDING); + } + + + /** {@inheritDoc} */ + @Override + protected List readPresetFile (final File file) + { + if (this.waitForDelivery ()) + return Collections.emptyList (); + + try + { + if (file.getName ().toLowerCase (Locale.US).endsWith (ProphetXTag.ARCHIVE_ENDING)) + return this.readArchive (file); + return this.readGroupFile (file); + } + catch (final IOException ex) + { + this.notifier.logError ("IDS_NOTIFY_ERR_LOAD_FILE", ex); + return Collections.emptyList (); + } + } + + + /** + * Read an instrument from its group file. The samples are located relative to the folder of + * the group file. + * + * @param file The group file + * @return The multi-sample source + * @throws IOException Could not read the group file + */ + private List readGroupFile (final File file) throws IOException + { + final File instrumentFolder = file.getAbsoluteFile ().getParentFile (); + // On the device the instrument folder lies in one of the fixed category folders + final File categoryFolder = instrumentFolder.getParentFile (); + final int folderCategory = categoryFolder == null ? -1 : ProphetXTag.getCategoryIndex (categoryFolder.getName ()); + + final IInstrumentFolder folder = new IInstrumentFolder () + { + @Override + public ISampleData loadSample (final String filePath) throws IOException + { + final File sampleFile = ProphetXDetector.this.createCanonicalFile (instrumentFolder, filePath); + if (!sampleFile.exists ()) + { + ProphetXDetector.this.notifier.logError ("IDS_NOTIFY_ERR_SAMPLE_DOES_NOT_EXIST", sampleFile.getAbsolutePath ()); + return null; + } + return createSampleData (sampleFile, ProphetXDetector.this.notifier); + } + + + @Override + public String loadVelocityVolumes () throws IOException + { + final File volumeFile = findFileIgnoreCase (instrumentFolder, ProphetXTag.VOLUME_FILE); + return volumeFile.exists () ? ProphetXDetector.this.loadTextFile (volumeFile) : null; + } + }; + + return this.parseGroupFile (file, FileUtils.getNameWithoutType (file), this.loadTextFile (file), folderCategory, folder); + } + + + /** + * Read an instrument from an import archive, which contains the instrument folder with its + * group file and samples. + * + * @param file The archive + * @return The multi-sample source + * @throws IOException Could not read the archive + */ + private List readArchive (final File file) throws IOException + { + try (final ZipFile zipFile = new ZipFile (file)) + { + final ZipEntry groupEntry = findGroupEntry (zipFile); + if (groupEntry == null) + { + this.notifier.log ("IDS_PROPHETX_NOT_AN_ARCHIVE", file.getName ()); + return Collections.emptyList (); + } + + final String groupEntryName = groupEntry.getName (); + final int slashIndex = groupEntryName.lastIndexOf ('/'); + final String entryFolder = slashIndex < 0 ? "" : groupEntryName.substring (0, slashIndex + 1); + final String name = FileUtils.getNameWithoutType (new File (groupEntryName)); + + // On the USB drive the archive lies in one of the fixed category folders + final File categoryFolder = file.getAbsoluteFile ().getParentFile (); + final int folderCategory = categoryFolder == null ? -1 : ProphetXTag.getCategoryIndex (categoryFolder.getName ()); + + final IInstrumentFolder folder = new IInstrumentFolder () + { + @Override + public ISampleData loadSample (final String filePath) throws IOException + { + final String entryName = entryFolder + filePath.replace ('\\', '/'); + final ZipEntry sampleEntry = findEntry (zipFile, entryName); + if (sampleEntry == null) + { + ProphetXDetector.this.notifier.logError ("IDS_NOTIFY_ERR_SAMPLE_DOES_NOT_EXIST", file.getName () + ": " + entryName); + return null; + } + return ProphetXDetector.this.createSampleData (file, new File (sampleEntry.getName ())); + } + + + @Override + public String loadVelocityVolumes () throws IOException + { + final ZipEntry volumeEntry = findEntry (zipFile, entryFolder + ProphetXTag.VOLUME_FILE); + return volumeEntry == null ? null : readText (zipFile, volumeEntry); + } + }; + + return this.parseGroupFile (file, name, readText (zipFile, groupEntry), folderCategory, folder); + } + } + + + /** + * Parse the content of a group file into a multi-sample source. + * + * @param sourceFile The group file or the archive + * @param fileName The name of the group file, which the device shows for the instrument + * @param content The text of the group file + * @param folderCategory The index of the category folder in which the instrument lies or -1 + * if it does not lie in one + * @param folder Reads the files of the instrument folder + * @return The multi-sample source + * @throws IOException Could not read a file of the instrument + */ + private List parseGroupFile (final File sourceFile, final String fileName, final String content, final int folderCategory, final IInstrumentFolder folder) throws IOException + { + final List> rows = parseTable (content); + if (rows == null) + { + this.notifier.logError ("IDS_PROPHETX_NO_HEADER", sourceFile.getAbsolutePath ()); + return Collections.emptyList (); + } + + // The samples of a round robin set carry the same number, so each number becomes a group + // which is played in turn; the samples without a number form the first group + final Map groups = new TreeMap<> (); + int columnCategory = -1; + String instrumentName = null; + for (final Map row: rows) + { + if (this.waitForDelivery ()) + return Collections.emptyList (); + + final String filePath = row.get (ProphetXTag.COLUMN_FILE_PATH); + if (filePath == null || filePath.isBlank ()) + continue; + + // The firmware keeps the category and the name of the first row which states them + if (columnCategory < 0) + columnCategory = ProphetXTag.getCategoryIndex (row.get (ProphetXTag.COLUMN_CATEGORY)); + if (instrumentName == null) + instrumentName = row.get (ProphetXTag.COLUMN_INSTRUMENT); + + final ISampleData sampleData = folder.loadSample (filePath); + if (sampleData == null) + continue; + final ISampleZone zone = createZone (filePath, sampleData, row); + + // The firmware reads 0 as the first number of a set + int roundRobin = getInt (row, ProphetXTag.COLUMN_ROUND_ROBIN, ProphetXTag.NOT_SET); + if (roundRobin == 0) + roundRobin = 1; + if (roundRobin > 0) + { + zone.setPlayLogic (PlayLogic.ROUND_ROBIN); + zone.setSequencePosition (roundRobin); + } + final Integer groupNumber = Integer.valueOf (Math.max (roundRobin, 0)); + groups.computeIfAbsent (groupNumber, number -> new DefaultGroup (number.intValue () == 0 ? "Group 1" : "Round Robin " + number)).addSampleZone (zone); + } + + if (groups.isEmpty ()) + { + this.notifier.logError ("IDS_PROPHETX_NO_SAMPLES", sourceFile.getAbsolutePath ()); + return Collections.emptyList (); + } + + final List groupList = new ArrayList<> (groups.values ()); + this.applyVelocityVolumes (sourceFile, groupList, folder); + + final String name = instrumentName == null || instrumentName.isBlank () ? fileName : instrumentName; + final IMultisampleSource multisampleSource = this.createMultisampleSource (sourceFile, name, groupList); + // The folder decides on the device in which category the instrument shows up, the column + // is only stored + final int category = folderCategory >= 0 ? folderCategory : columnCategory; + if (category >= 0) + multisampleSource.getMetadata ().setCategory (ProphetXTag.toModelCategory (category)); + return Collections.singletonList (multisampleSource); + } + + + /** + * Create a zone from one row of the group file. + * + * @param filePath The file path of the sample + * @param sampleData The sample data + * @param row The values of the row by column name + * @return The zone + * @throws IOException Could not read the metadata of the sample + */ + private static ISampleZone createZone (final String filePath, final ISampleData sampleData, final Map row) throws IOException + { + final ISampleZone zone = new DefaultSampleZone (FileUtils.getNameWithoutType (new File (filePath)), sampleData); + + final int keyLow = Math.clamp (getInt (row, ProphetXTag.COLUMN_LOW_NOTE, 0), 0, 127); + zone.setKeyLow (keyLow); + zone.setKeyHigh (Math.clamp (getInt (row, ProphetXTag.COLUMN_HIGH_NOTE, 127), keyLow, 127)); + final int velocityLow = Math.clamp (getInt (row, ProphetXTag.COLUMN_LOW_VELOCITY, 0), 0, 127); + zone.setVelocityLow (velocityLow); + zone.setVelocityHigh (Math.clamp (getInt (row, ProphetXTag.COLUMN_HIGH_VELOCITY, 127), velocityLow, 127)); + + // The device plays a key at the ratio of the frequency of the key and the frequency at + // which the file sounds, so the root key is the closest key and the rest is the tuning + final double pitchInHertz = getDouble (row, ProphetXTag.COLUMN_PITCH, ProphetXTag.DEFAULT_PITCH); + if (pitchInHertz > 0) + { + final double note = 69 + 12 * Math.log (pitchInHertz / ProphetXTag.DEFAULT_PITCH) / Math.log (2); + final int root = Math.clamp (Math.round (note), 0, 127); + zone.setKeyRoot (root); + zone.setTuning (root - note); + } + + // A sample with a mono pitch plays untransposed on every key + if (ProphetXTag.YES.equalsIgnoreCase (row.get (ProphetXTag.COLUMN_MONO_PITCH))) + zone.setKeyTracking (0); + + final int loopStart = getInt (row, ProphetXTag.COLUMN_LOOP_START, ProphetXTag.NOT_SET); + final int loopEnd = getInt (row, ProphetXTag.COLUMN_LOOP_END, ProphetXTag.NOT_SET); + if (loopStart >= 0 || loopEnd >= 0) + { + // The device loops from the start of the file if only the end is given and to its end + // if only the start is given; both frames are played + final int lastFrame = sampleData.getAudioMetadata ().getNumberOfSamples () - 1; + final int start = Math.max (loopStart, 0); + final int end = loopEnd < 0 ? lastFrame : Math.min (loopEnd, lastFrame); + if (start < end) + { + final ISampleLoop loop = new DefaultSampleLoop (); + loop.setType (LoopType.FORWARDS); + loop.setStart (start); + loop.setEnd (end); + zone.addLoop (loop); + } + } + + return zone; + } + + + /** + * Apply the optional velocity volumes of the instrument, one gain factor per velocity, to the + * amplitude velocity modulators of all zones. The device uses its built-in curve instead if + * the instrument has no such file, which is left to the defaults of the model. + * + * @param sourceFile The group file or the archive, for the error message + * @param groups The groups with the zones + * @param folder Reads the files of the instrument folder + */ + private void applyVelocityVolumes (final File sourceFile, final List groups, final IInstrumentFolder folder) + { + final String content; + try + { + content = folder.loadVelocityVolumes (); + } + catch (final IOException ex) + { + this.notifier.logError ("IDS_PROPHETX_VOLUME_FILE", sourceFile.getAbsolutePath (), ex.getMessage ()); + return; + } + if (content == null) + return; + + // The firmware reads up to 128 numbers, the rest stays at 1 + final double [] volumes = new double [NUM_VELOCITIES]; + Arrays.fill (volumes, 1); + final Matcher matcher = VOLUME_NUMBER.matcher (content); + for (int i = 0; i < NUM_VELOCITIES && matcher.find (); i++) + volumes[i] = Double.parseDouble (matcher.group ()); + + // The response of the model is 1 - depth + depth * (velocity / 127) ^ (3 ^ curve), + // therefore the depth is the swing from the lowest velocity to the highest one and the + // curve is the power law which fits the shape in between + final double maximum = volumes[NUM_VELOCITIES - 1]; + if (maximum <= 0) + return; + final double depth = Math.clamp (1 - volumes[0] / maximum, 0, 1); + double curve = 0; + if (depth > 0) + { + double sumXY = 0; + double sumXX = 0; + for (int i = 1; i < NUM_VELOCITIES - 1; i++) + { + final double response = (volumes[i] / maximum - (1 - depth)) / depth; + if (response <= 0 || response >= 1) + continue; + final double x = Math.log (i / (double) (NUM_VELOCITIES - 1)); + sumXY += x * Math.log (response); + sumXX += x * x; + } + if (sumXX > 0) + curve = Math.clamp (Math.log (sumXY / sumXX) / Math.log (3), -1, 1); + } + + for (final IGroup group: groups) + for (final ISampleZone zone: group.getSampleZones ()) + { + final IModulator modulator = zone.getAmplitudeVelocityModulator (); + modulator.setDepth (depth); + modulator.setCurve (curve); + } + } + + + /** + * Parse the table of a group file. The first line names the columns in any order, each further + * line is one sample. Like the firmware, only tabs separate the values and empty values are + * dropped, so the values of a line are matched to the columns by their position among the + * present values. + * + * @param content The text of the group file + * @return The rows with their values by column name or null if there is no header line + */ + private static List> parseTable (final String content) + { + final List> rows = new ArrayList<> (); + String [] columns = null; + for (final String line: content.split ("\r\n|\r|\n")) + { + final String [] values = splitValues (line); + if (values.length == 0) + continue; + if (columns == null) + { + columns = values; + continue; + } + final Map row = new HashMap<> (); + for (int i = 0; i < values.length && i < columns.length; i++) + row.putIfAbsent (columns[i], values[i]); + rows.add (row); + } + return columns == null ? null : rows; + } + + + /** + * Split a line into its values. + * + * @param line The line + * @return The values, without empty ones + */ + private static String [] splitValues (final String line) + { + final List values = new ArrayList<> (); + for (final String value: line.split ("\t")) + { + final String stripped = value.strip (); + if (!stripped.isEmpty ()) + values.add (stripped); + } + return values.toArray (new String [values.size ()]); + } + + + /** + * Find the group file in an import archive. The device expects it as + * <name>/<name>.grp; if there is no such entry the first group file wins. + * + * @param zipFile The archive + * @return The entry of the group file or null if there is none + */ + private static ZipEntry findGroupEntry (final ZipFile zipFile) + { + ZipEntry firstGroupEntry = null; + final Enumeration entries = zipFile.entries (); + while (entries.hasMoreElements ()) + { + final ZipEntry entry = entries.nextElement (); + final String entryName = entry.getName (); + if (entry.isDirectory () || entryName.startsWith (MACOS_RESOURCE_FOLDER) || !entryName.toLowerCase (Locale.US).endsWith (ProphetXTag.GROUP_FILE_ENDING)) + continue; + final File entryFile = new File (entryName); + final File parent = entryFile.getParentFile (); + if (parent != null && parent.getName ().equals (FileUtils.getNameWithoutType (entryFile))) + return entry; + if (firstGroupEntry == null) + firstGroupEntry = entry; + } + return firstGroupEntry; + } + + + /** + * Find an entry in an archive. The device matches the names exactly, the search here also + * accepts a different case. + * + * @param zipFile The archive + * @param entryName The name of the entry + * @return The entry or null if it does not exist + */ + private static ZipEntry findEntry (final ZipFile zipFile, final String entryName) + { + final ZipEntry entry = zipFile.getEntry (entryName); + if (entry != null) + return entry; + + final Enumeration entries = zipFile.entries (); + while (entries.hasMoreElements ()) + { + final ZipEntry candidate = entries.nextElement (); + if (candidate.getName ().equalsIgnoreCase (entryName)) + return candidate; + } + return null; + } + + + /** + * Read a text entry of an archive. + * + * @param zipFile The archive + * @param entry The entry + * @return The text + * @throws IOException Could not read the entry + */ + private static String readText (final ZipFile zipFile, final ZipEntry entry) throws IOException + { + try (final InputStream inputStream = zipFile.getInputStream (entry)) + { + return new String (inputStream.readAllBytes (), StandardCharsets.UTF_8); + } + } + + + /** + * Get an integer value of a row. Like the firmware, which uses atoi, the fraction of a decimal + * number is dropped. + * + * @param row The row + * @param column The name of the column + * @param defaultValue The value to return if the column is absent or not a number + * @return The value + */ + private static int getInt (final Map row, final String column, final int defaultValue) + { + final double value = getDouble (row, column, defaultValue); + return value < Integer.MIN_VALUE || value > Integer.MAX_VALUE ? defaultValue : (int) value; + } + + + /** + * Get a decimal value of a row. + * + * @param row The row + * @param column The name of the column + * @param defaultValue The value to return if the column is absent or not a number + * @return The value + */ + private static double getDouble (final Map row, final String column, final double defaultValue) + { + final String text = row.get (column); + if (text == null) + return defaultValue; + try + { + return Double.parseDouble (text); + } + catch (final NumberFormatException _) + { + return defaultValue; + } + } +} diff --git a/src/main/java/de/mossgrabers/convertwithmoss/format/sequential/prophetx/ProphetXTag.java b/src/main/java/de/mossgrabers/convertwithmoss/format/sequential/prophetx/ProphetXTag.java new file mode 100644 index 00000000..1683a4e9 --- /dev/null +++ b/src/main/java/de/mossgrabers/convertwithmoss/format/sequential/prophetx/ProphetXTag.java @@ -0,0 +1,295 @@ +// 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.sequential.prophetx; + +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; + +import de.mossgrabers.convertwithmoss.format.TagDetector; + + +/** + * Constants of the Sequential Prophet X / XL sample instrument format. An instrument is a folder + * which contains its samples as WAV files and a group file (ending .grp) with the key, + * velocity and loop mapping. The device imports such folders as ZIP archives from a USB drive: + * px/<bank>/<category>/<name>.zip. The format was reverse-engineered from + * the OS 2.2.2 firmware, see documentation/design/PROPHET_X_GRP_FORMAT.md. + * + * @author Jürgen Moßgraber + */ +public class ProphetXTag +{ + /** The ending of the group file which maps the samples of an instrument. */ + public static final String GROUP_FILE_ENDING = ".grp"; + /** The ending of an import archive. */ + public static final String ARCHIVE_ENDING = ".zip"; + /** The folder at the root of the USB drive below which the device looks for import archives. */ + public static final String USB_FOLDER = "px"; + /** The number of user banks (u00 to u31) of the device. */ + public static final int NUM_USER_BANKS = 32; + /** The optional file next to the group file with one gain factor per velocity. */ + public static final String VOLUME_FILE = "Volume.txt"; + + /** The sample rate of the audio engine. The device ignores the sample rate of a WAV file. */ + public static final int SAMPLE_RATE = 48000; + /** The only bit resolution which the device plays. */ + public static final int BIT_RESOLUTION = 16; + /** The maximum number of samples of an instrument, the limit of the mapping utility of the device. */ + public static final int MAX_SAMPLES = 128; + /** The maximum length of an instrument name; the firmware copies it into a buffer of 64 bytes. */ + public static final int MAX_NAME_LENGTH = 63; + /** The value of a column which is not set. */ + public static final int NOT_SET = -1; + /** The pitch which the firmware assumes for a sample without a pitch. */ + public static final double DEFAULT_PITCH = 440.0; + + /** The column with the path of the sample file, relative to the instrument folder. */ + public static final String COLUMN_FILE_PATH = "File Path"; + /** The column with the first key of the zone. */ + public static final String COLUMN_LOW_NOTE = "Low Midi Note"; + /** The column with the last key of the zone. */ + public static final String COLUMN_HIGH_NOTE = "High Midi Note"; + /** The column with the lowest velocity of the zone. */ + public static final String COLUMN_LOW_VELOCITY = "Low Velocity"; + /** The column with the highest velocity of the zone. */ + public static final String COLUMN_HIGH_VELOCITY = "High Velocity"; + /** The column with the first frame of the loop. */ + public static final String COLUMN_LOOP_START = "Loop Start"; + /** The column with the last frame of the loop, inclusive. */ + public static final String COLUMN_LOOP_END = "Loop End"; + /** The column with the 1-based number of the sample in its round robin set. */ + public static final String COLUMN_ROUND_ROBIN = "Round Robin Number"; + /** The column with the frequency at which the file sounds. */ + public static final String COLUMN_PITCH = "Pitch in Hertz"; + /** The column which switches the key tracking off. */ + public static final String COLUMN_MONO_PITCH = "Mono Pitch"; + /** The column which tells if the file is stereo or mono. */ + public static final String COLUMN_STEREO_MONO = "Stereo/Mono File"; + /** The column with the category of the instrument. */ + public static final String COLUMN_CATEGORY = "Category"; + /** The column with the name of the instrument. */ + public static final String COLUMN_INSTRUMENT = "Instrument Name"; + /** The column with the channel to use when a stereo file is collapsed to mono. */ + public static final String COLUMN_MONO_COLLAPSE = "Mono Collapse"; + /** The column with the unique identifier of the instrument. */ + public static final String COLUMN_UUID = "UUID"; + + /** All columns in the order in which the firmware defines them. */ + public static final String [] COLUMNS = + { + COLUMN_FILE_PATH, + COLUMN_LOW_NOTE, + COLUMN_HIGH_NOTE, + COLUMN_LOW_VELOCITY, + COLUMN_HIGH_VELOCITY, + COLUMN_LOOP_START, + COLUMN_LOOP_END, + COLUMN_ROUND_ROBIN, + COLUMN_PITCH, + COLUMN_MONO_PITCH, + COLUMN_STEREO_MONO, + COLUMN_CATEGORY, + COLUMN_INSTRUMENT, + COLUMN_MONO_COLLAPSE, + COLUMN_UUID + }; + + /** The value of a flag column which is set. */ + public static final String YES = "Y"; + /** The value of a flag column which is not set. */ + public static final String NO = "N"; + /** The value of the stereo/mono column for a stereo file. */ + public static final String STEREO = "S"; + /** The value of the stereo/mono column for a mono file. */ + public static final String MONO = "M"; + /** The value of the mono collapse column which mixes both channels, the default of the firmware. */ + public static final String COLLAPSE_BOTH = "B"; + + /** The category folders of the device in the order of their number. */ + public static final String [] CATEGORY_FOLDERS = + { + "01 Ambience", + "02 Bass", + "03 Brass", + "04 Choir", + "05 Cinematic", + "06 Drums", + "07 Effects", + "08 Ethnic", + "09 Guitar", + "10 Keyboard", + "11 Percussion", + "12 Tonal Perc", + "13 Piano", + "14 Strings", + "15 Synth", + "16 Vox", + "17 Winds" + }; + + /** The category names without their number, as the firmware compares them. */ + public static final String [] CATEGORY_NAMES = + { + "Ambience", + "Bass", + "Brass", + "Choir", + "Cinematic", + "Drums", + "Effects", + "Ethnic", + "Guitar", + "Keyboard", + "Percussion", + "Tonal Perc", + "Piano", + "Strings", + "Synth", + "Vox", + "Winds" + }; + + /** The category of a source whose category is unknown or has no counterpart on the device. */ + public static final int DEFAULT_CATEGORY = 14; + + /** The category of the model which is the closest to each category of the device. */ + private static final String [] MODEL_CATEGORIES = + { + TagDetector.CATEGORY_DRONE, + TagDetector.CATEGORY_BASS, + TagDetector.CATEGORY_BRASS, + TagDetector.CATEGORY_VOCAL, + TagDetector.CATEGORY_HITS, + TagDetector.CATEGORY_DRUM, + TagDetector.CATEGORY_FX, + TagDetector.CATEGORY_WORLD, + TagDetector.CATEGORY_GUITAR, + TagDetector.CATEGORY_KEYBOARD, + TagDetector.CATEGORY_PERCUSSION, + TagDetector.CATEGORY_CHROMATIC_PERCUSSION, + TagDetector.CATEGORY_PIANO, + TagDetector.CATEGORY_STRINGS, + TagDetector.CATEGORY_SYNTH, + TagDetector.CATEGORY_VOCAL, + TagDetector.CATEGORY_WINDS + }; + + private static final Map CATEGORY_OF_MODEL = new HashMap<> (); + static + { + // The categories of the model which have a counterpart of the same meaning; a later entry + // wins for the two which map to the same one (Choir and Vox both stand for Vocal) + for (int i = 0; i < MODEL_CATEGORIES.length; i++) + CATEGORY_OF_MODEL.put (MODEL_CATEGORIES[i], Integer.valueOf (i)); + + // The categories of the model which the device does not know, sorted into the closest one + CATEGORY_OF_MODEL.put (TagDetector.CATEGORY_ACOUSTIC_DRUM, Integer.valueOf (5)); + CATEGORY_OF_MODEL.put (TagDetector.CATEGORY_KICK, Integer.valueOf (5)); + CATEGORY_OF_MODEL.put (TagDetector.CATEGORY_SNARE, Integer.valueOf (5)); + CATEGORY_OF_MODEL.put (TagDetector.CATEGORY_HI_HAT, Integer.valueOf (5)); + CATEGORY_OF_MODEL.put (TagDetector.CATEGORY_CLAP, Integer.valueOf (5)); + CATEGORY_OF_MODEL.put (TagDetector.CATEGORY_BELL, Integer.valueOf (11)); + CATEGORY_OF_MODEL.put (TagDetector.CATEGORY_DESTRUCTION, Integer.valueOf (6)); + CATEGORY_OF_MODEL.put (TagDetector.CATEGORY_LOOPS, Integer.valueOf (6)); + CATEGORY_OF_MODEL.put (TagDetector.CATEGORY_ENSEMBLE, Integer.valueOf (13)); + CATEGORY_OF_MODEL.put (TagDetector.CATEGORY_ORCHESTRAL, Integer.valueOf (13)); + CATEGORY_OF_MODEL.put (TagDetector.CATEGORY_ORGAN, Integer.valueOf (9)); + CATEGORY_OF_MODEL.put (TagDetector.CATEGORY_PIPE, Integer.valueOf (16)); + CATEGORY_OF_MODEL.put (TagDetector.CATEGORY_CHIP, Integer.valueOf (14)); + CATEGORY_OF_MODEL.put (TagDetector.CATEGORY_LEAD, Integer.valueOf (14)); + CATEGORY_OF_MODEL.put (TagDetector.CATEGORY_MONOSYNTH, Integer.valueOf (14)); + CATEGORY_OF_MODEL.put (TagDetector.CATEGORY_PAD, Integer.valueOf (14)); + CATEGORY_OF_MODEL.put (TagDetector.CATEGORY_PLUCK, Integer.valueOf (14)); + } + + + /** + * Constructor. + */ + private ProphetXTag () + { + // Intentionally empty + } + + + /** + * Get the folder name of a user bank. + * + * @param index The index of the bank, 0 to {@link #NUM_USER_BANKS} - 1 + * @return The folder name, e.g. 'u00' + */ + public static String getUserBank (final int index) + { + return String.format (Locale.US, "u%02d", Integer.valueOf (index)); + } + + + /** + * Get the index of a user bank from its folder name. + * + * @param text The folder name, e.g. 'u07', or only its number + * @return The index of the bank or -1 if the text is not a user bank + */ + public static int getUserBankIndex (final String text) + { + if (text == null) + return -1; + final String bank = text.strip ().toLowerCase (Locale.US); + final String number = bank.startsWith ("u") ? bank.substring (1) : bank; + try + { + final int index = Integer.parseInt (number); + return index >= 0 && index < NUM_USER_BANKS ? index : -1; + } + catch (final NumberFormatException _) + { + return -1; + } + } + + + /** + * Get the index of a category from its folder name or its name. + * + * @param text The folder name (e.g. '15 Synth') or the name (e.g. 'Synth') + * @return The index of the category or -1 if the text is not a category of the device + */ + public static int getCategoryIndex (final String text) + { + if (text == null) + return -1; + final String name = text.strip (); + for (int i = 0; i < CATEGORY_FOLDERS.length; i++) + if (CATEGORY_FOLDERS[i].equalsIgnoreCase (name) || CATEGORY_NAMES[i].equalsIgnoreCase (name)) + return i; + return -1; + } + + + /** + * Get the category of the device which is the closest to a category of the model. + * + * @param category The category of the model, see the constants of {@link TagDetector} + * @return The index of the category of the device + */ + public static int fromModelCategory (final String category) + { + final Integer index = category == null ? null : CATEGORY_OF_MODEL.get (category); + return index == null ? DEFAULT_CATEGORY : index.intValue (); + } + + + /** + * Get the category of the model which is the closest to a category of the device. + * + * @param index The index of the category of the device + * @return The category of the model, see the constants of {@link TagDetector} + */ + public static String toModelCategory (final int index) + { + return MODEL_CATEGORIES[Math.clamp (index, 0, MODEL_CATEGORIES.length - 1)]; + } +} diff --git a/src/main/resources/Strings.properties b/src/main/resources/Strings.properties index 7fb356ca..71dbfd22 100644 --- a/src/main/resources/Strings.properties +++ b/src/main/resources/Strings.properties @@ -498,6 +498,22 @@ IDS_OPXY_DROPPED_VELOCITY_LAYERS=Dropped %1 zone(s) which cover an already mappe IDS_OPXY_SAMPLE_TOO_LONG=%1 sample(s) are longer than the %2 seconds which the device plays; use the processing option to trim them.\n IDS_OPXY_TOO_MANY_REGIONS=The preset has %1 regions but the device plays at most %2, the rest is dropped.\n +IDS_PROPHETX_SEPARATOR=Sequential Prophet X +IDS_PROPHETX_CATEGORY=Category +IDS_PROPHETX_CATEGORY_FROM_SOURCE=From the source +IDS_PROPHETX_BANK=User bank +IDS_PROPHETX_WRITE_ARCHIVE=Write the import archive +IDS_PROPHETX_NOT_AN_ARCHIVE=Skipped '%1': the archive contains no Prophet X group file.\n +IDS_PROPHETX_NO_HEADER=The group file '%1' has no header line which names its columns.\n +IDS_PROPHETX_NO_SAMPLES=The group file '%1' references no samples which could be read.\n +IDS_PROPHETX_VOLUME_FILE=Could not read the velocity volumes '%1': %2\n +IDS_PROPHETX_UNSUPPORTED_CHANNELS=The device plays mono or stereo samples only; the sample '%1' has %2 channels and is skipped.\n +IDS_PROPHETX_TOO_MANY_SAMPLES=The instrument has %1 samples, the mapping utility of the device allows at most %2.\n +IDS_PROPHETX_LOOP_TYPE=%1 loop(s) play backwards or alternating, the device only loops forwards.\n +IDS_PROPHETX_REVERSED=%1 zone(s) play reversed, which the device cannot; the samples are written as they are.\n +IDS_PROPHETX_UNKNOWN_CATEGORY=Unknown Prophet X category: %1. Use one of the category folders of the device, e.g. '15 Synth' or 'Synth'.\n +IDS_PROPHETX_UNKNOWN_BANK=Unknown Prophet X user bank: %1. Use u00 to u31.\n + IDS_PTI_NOT_AN_INSTRUMENT=The file '%1' is not a Polyend Tracker instrument.\n IDS_PTI_NO_AUDIO_DATA=The Polyend Tracker instrument '%1' does not contain any audio data.\n IDS_PTI_ONLY_ONE_SAMPLE=A Polyend Tracker instrument holds only one sample. Storing the sample '%1', all others are ignored.\n From defd44dfe57ee222ce15d3139c1c96445e2501e2 Mon Sep 17 00:00:00 2001 From: Douglas Carmichael Date: Tue, 15 Sep 2026 13:01:37 -0400 Subject: [PATCH 2/3] Sequential Prophet X: document what the programs of the device hold The modulation of a sound (envelopes, filter, LFOs, mod matrix, glide, bend range) is not part of an instrument but of the program which plays it. The design document now records what the User's Guide and the factory bank show about the program dump: the packed 4096-byte layout with the NRPN number as the byte offset, the name at offset 418 and the instrument reference by category and index, so that a program writer can be built on it later. The UUID of an instrument is described as what the firmware does with it. --- documentation/README-FORMATS.md | 2 +- documentation/design/PROPHET_X_GRP_FORMAT.md | 38 +++++++++++++++++-- .../sequential/prophetx/ProphetXCreator.java | 6 +-- 3 files changed, 38 insertions(+), 8 deletions(-) diff --git a/documentation/README-FORMATS.md b/documentation/README-FORMATS.md index 89d00147..7412458a 100644 --- a/documentation/README-FORMATS.md +++ b/documentation/README-FORMATS.md @@ -871,7 +871,7 @@ The Prophet X and Prophet XL are hybrid synthesizers from Sequential whose sampl Reading takes both plain instrument folders (via their group file) and the import archives. Each line becomes a zone with its key range, velocity range, loop, root key and fine tuning (both from the frequency), key tracking (a *Mono Pitch* sample plays untransposed on every key) and its round robin number, which puts the samples of a set into groups that play in turn. The category comes from the category folder in which the instrument lies, or from the *Category* column if it lies in none, and the name from the *Instrument Name* column. The velocity volumes of *Volume.txt* are fitted to the amplitude velocity modulation (depth and curve); without the file the device uses its built-in curve, which is left to the defaults. -Writing creates, by default, the import archive in the USB layout: copy the *px* folder onto a USB drive which was formatted by the device, insert it into the sample import port and import the user bank from the Global menu of the device. The category is the closest one of the 17 categories of the device to the category of the source, unless one is selected. The samples are written as 16 bit / 48 kHz WAV files: the device ignores the sample rate of a file and plays everything at 48 kHz, so any other rate would sound transposed. A zone with a play range is cut to it, since the format has no play range. Only the first loop of a zone is written and always as a forward loop; a backwards or alternating loop and reversed playback are reported. Round robin and random zones get their number in their set and the device picks one of them at random; zones which trigger on release are skipped. The velocity response is written to *Volume.txt* if all zones share one which differs from the default of full, linear velocity. The mapping utility of the device allows at most 128 samples per instrument, more are reported but written. The programs of the device address an instrument by a UUID, which is derived from the bank, the category and the name, so a source which is converted again gives the same instrument. When a Prophet X instrument is converted to a Prophet X instrument again, enable the flat output, otherwise the *px* folder of the source is recreated below the destination folder and the new *px* folder lands inside it. Written instruments are not yet verified on hardware. +Writing creates, by default, the import archive in the USB layout: copy the *px* folder onto a USB drive which was formatted by the device, insert it into the sample import port and import the user bank from the Global menu of the device. The category is the closest one of the 17 categories of the device to the category of the source, unless one is selected. The samples are written as 16 bit / 48 kHz WAV files: the device ignores the sample rate of a file and plays everything at 48 kHz, so any other rate would sound transposed. A zone with a play range is cut to it, since the format has no play range. Only the first loop of a zone is written and always as a forward loop; a backwards or alternating loop and reversed playback are reported. Round robin and random zones get their number in their set and the device picks one of them at random; zones which trigger on release are skipped. The velocity response is written to *Volume.txt* if all zones share one which differs from the default of full, linear velocity. The mapping utility of the device allows at most 128 samples per instrument, more are reported but written. The device identifies an instrument by a UUID as well as by its name; the UUID is derived from the bank, the category and the name, so a source which is converted again gives the same instrument. Envelopes, filter and LFO settings are not part of an instrument on the device but of the program which plays it, so they are not written. When a Prophet X instrument is converted to a Prophet X instrument again, enable the flat output, otherwise the *px* folder of the source is recreated below the destination folder and the new *px* folder lands inside it. Written instruments are not yet verified on hardware. ### Destination Options diff --git a/documentation/design/PROPHET_X_GRP_FORMAT.md b/documentation/design/PROPHET_X_GRP_FORMAT.md index 254863c4..ca4e62a4 100644 --- a/documentation/design/PROPHET_X_GRP_FORMAT.md +++ b/documentation/design/PROPHET_X_GRP_FORMAT.md @@ -52,10 +52,11 @@ Instruments live on the internal drive: * `/hd//GroupInfo.txt` only caches the size of the group (`Group Size: `); it is not part of an instrument. -The instrument dictionary (`CInstrumentDictionary::Find`) looks an instrument up by its 16-byte -UUID first and falls back to its name, which is how programs reference the instruments they play. -ConvertWithMoss therefore writes a UUID which is derived (version 3, name-based) from the user -bank, the category and the name, so converting a source again yields the same instrument. +The instrument dictionary (`CInstrumentDictionary::Find`) identifies an instrument either by its +group, category and index or by its 16-byte UUID with a fallback to its name (the programs +themselves store category and index, see the end of this document). ConvertWithMoss writes a UUID +which is derived (version 3, name-based) from the user bank, the category and the name, so +converting a source again yields an instrument with the same identity. ## Import from USB @@ -164,3 +165,32 @@ the model (depth 1, linear), so that an unspecified source keeps the built-in ta * 16-bit PCM only (`Only support 16-bit mono or stereo sample files`), one or two channels. * Any sample rate is accepted but ignored, see above: use 48 kHz. * The frame count is `data size / 2 / channels`. + +## What the instrument cannot carry: modulation + +Envelopes, the filter, the LFOs, the modulation matrix, glide, unison, pitch bend and the loop +playback modes are parameters of the *program* which plays an instrument, so a converted +multi-sample loses them unless a program is written as well. What is known about programs, from +the User's Guide 1.2 (Appendix E) and the factory bank `PX_Programs_v2.0.syx`: + +* A program is transmitted as `F0 01 30 02 F7` (edit buffer: + `F0 01 30 03 F7`), the data being 4096 bytes in the DSI "packed MS bit" format (8 MIDI + bytes per 7 data bytes, 4683 MIDI bytes). +* The NRPN number of a parameter is its byte offset in the data: layer A at 0-2047, layer B at + 2048-4095, parameters with a range above 255 (`Inst1Start` 0-999 etc.) take two bytes, little + endian. The program name is 20 characters at offset 418 (2466 for layer B). +* A program references its two instruments per layer by `InstNCategory` (0-16) and `InstNSelect` + (0-99), i.e. by the index of the instrument inside its category folder - which is the alphabetical + position among whatever the user has installed there. No UUID or name is stored in the program; + the group (user bank) of a user instrument is an OS 2.x addition whose offset the 1.2 manual does + not list (all factory programs reference group `f00`). +* The programs are stored and imported by the panel processor; the OS image analysed here only + forwards `PxPrograms.prg` from the USB drive to it and reports `.syx` files found in + `px//`. Loading a program file therefore goes through a SysEx librarian (or that folder), + not through the instrument import. + +Writing such a program per converted instrument is possible (amplitude envelope, filter, two LFOs +as vibrato and tremolo, velocity to amplifier, bend range, glide, mono mode, program name), but +the value laws (envelope times, LFO rates, cutoff) still need calibration and the instrument index +only holds as long as nothing else is installed in front of it in that category. It is not +implemented. diff --git a/src/main/java/de/mossgrabers/convertwithmoss/format/sequential/prophetx/ProphetXCreator.java b/src/main/java/de/mossgrabers/convertwithmoss/format/sequential/prophetx/ProphetXCreator.java index b97c9ac9..3e866f33 100644 --- a/src/main/java/de/mossgrabers/convertwithmoss/format/sequential/prophetx/ProphetXCreator.java +++ b/src/main/java/de/mossgrabers/convertwithmoss/format/sequential/prophetx/ProphetXCreator.java @@ -296,9 +296,9 @@ private static String createInstrumentName (final IMultisampleSource multisample private String createGroupFile (final List zones, final String name, final int category, final String bank) throws IOException { final String categoryName = ProphetXTag.CATEGORY_NAMES[category]; - // The programs of the device address an instrument by its UUID and fall back to its name, - // therefore the UUID is derived from the name and the location of the instrument: a source - // which is converted again gives the same instrument + // The device identifies an instrument by its UUID and falls back to its name, therefore + // the UUID is derived from the name and the location of the instrument: a source which is + // converted again gives an instrument with the same identity final String uuid = UUID.nameUUIDFromBytes ((bank + "/" + categoryName + "/" + name).getBytes (StandardCharsets.UTF_8)).toString ().replace ("-", ""); final List rows = new ArrayList<> (); From 619a4b3707dd1d5b577245e552f5f8cbecc6ede2 Mon Sep 17 00:00:00 2001 From: Douglas Carmichael Date: Tue, 15 Sep 2026 13:22:42 -0400 Subject: [PATCH 3/3] Sequential Prophet X: follow the conventions of PXToolkit, the writer whose archives play The free PXToolkit is an Electron app, so its writer could be read, and a third-party sample pack made with it (Goldbaby GBPX Free1) served as real archives. The detector reads all six of them; the creator now writes what that tool writes: keys which no zone covers map to an empty sample so that the device plays nothing on them instead of the nearest zone, the group file ends its lines with LF, the WAV files carry only their format and data chunk, and names are reduced to ASCII. The detector skips those empty filler samples and survives a sample row which cannot be read. The design document records the conventions, the pipe character mapping of the importer and the import path on the device (Global menu, Update Library, User, Update Now). --- documentation/CHANGELOG.md | 2 +- documentation/README-FORMATS.md | 4 +- documentation/design/PROPHET_X_GRP_FORMAT.md | 40 +++- .../sequential/prophetx/ProphetXCreator.java | 209 +++++++++++++++++- .../sequential/prophetx/ProphetXDetector.java | 25 ++- .../sequential/prophetx/ProphetXTag.java | 2 + src/main/resources/Strings.properties | 1 + 7 files changed, 262 insertions(+), 21 deletions(-) diff --git a/documentation/CHANGELOG.md b/documentation/CHANGELOG.md index 03d307ff..de93fe17 100644 --- a/documentation/CHANGELOG.md +++ b/documentation/CHANGELOG.md @@ -5,7 +5,7 @@ * Many thanks to Douglas Carmichael for plenty of contributions and fixes! * New: Added support for the E-mu Emulator (1981), the first Emulator (HFE, EMUFD, IMG). Written disks are not yet verified on hardware. * New: Added support for the E-mu Emax and Emax II (EM1, EB1, EM2, EB2, EM1FD, EM2FD, EZ1, EZ2, IMG, ISO, HFE). Written banks are not yet verified on hardware. -* New: Added support for the sample instruments of the Sequential Prophet X / XL (GRP, ZIP): the instrument folders with their group file and the ZIP archives which the device imports from a USB drive. Written instruments are not yet verified on hardware. +* New: Added support for the sample instruments of the Sequential Prophet X / XL (GRP, ZIP): the instrument folders with their group file and the ZIP archives which the device imports from a USB drive, as written by the mapping utility of 8Dio and by PXToolkit. Reading was verified on a third-party sample pack; written instruments follow the same conventions but are not yet verified on hardware. * New: If no category could be detected, another attempt is made by using all group and zone names. * Fixed: The main window had a minimum size of 1280x840 pixels and therefore did not fit onto smaller screens like the 1366x768 pixels of many laptops - it opened larger than the screen and could not be made smaller. The minimum and the initial size of the window are now limited to the visible area of the screen. * Fixed: 32-bit float samples (e.g. the WAV files which DirectWave writes) were always converted to 16 bit, even though the log announced the 24 bit of the destination format. A float sample is now converted to the highest resolution the destination supports - 24 bit for e.g. SoundFont 2, Bliss, Tonverk, DirectWave and FLAC, 32 bit for ALAC - and keeps its float format for destinations without a restriction, e.g. when only the chunks of a WAV file are updated. diff --git a/documentation/README-FORMATS.md b/documentation/README-FORMATS.md index 7412458a..2ec68c0b 100644 --- a/documentation/README-FORMATS.md +++ b/documentation/README-FORMATS.md @@ -869,9 +869,9 @@ WAV file can contain different sample formats. This converter supports (split) s The Prophet X and Prophet XL are hybrid synthesizers from Sequential whose sample engine plays multi-sampled instruments. An instrument is a folder which contains its samples as WAV files and a *group file* (ending *.grp*): a tab-separated table with a header line and one line per sample - the file name, the key and velocity range, the loop, the number in a round robin set, the frequency at which the sample sounds and whether it tracks the keyboard. An optional *Volume.txt* next to it holds one gain factor per velocity. The device imports such instruments from a USB drive as ZIP archives which lie in the folder *px/<user bank>/<category>/*, e.g. *px/u00/15 Synth/Rhodes.zip*, which is the layout that the official mapping utility of 8Dio creates. Sequential does not document the format; it was reverse-engineered from the OS 2.2.2 firmware, see *documentation/design/PROPHET_X_GRP_FORMAT.md*. -Reading takes both plain instrument folders (via their group file) and the import archives. Each line becomes a zone with its key range, velocity range, loop, root key and fine tuning (both from the frequency), key tracking (a *Mono Pitch* sample plays untransposed on every key) and its round robin number, which puts the samples of a set into groups that play in turn. The category comes from the category folder in which the instrument lies, or from the *Category* column if it lies in none, and the name from the *Instrument Name* column. The velocity volumes of *Volume.txt* are fitted to the amplitude velocity modulation (depth and curve); without the file the device uses its built-in curve, which is left to the defaults. +Reading takes both plain instrument folders (via their group file) and the import archives, including those which the free *PXToolkit* writes; the empty samples with which that tool keeps unmapped keys silent are skipped. Each line becomes a zone with its key range, velocity range, loop, root key and fine tuning (both from the frequency), key tracking (a *Mono Pitch* sample plays untransposed on every key) and its round robin number, which puts the samples of a set into groups that play in turn. The category comes from the category folder in which the instrument lies, or from the *Category* column if it lies in none, and the name from the *Instrument Name* column. The velocity volumes of *Volume.txt* are fitted to the amplitude velocity modulation (depth and curve); without the file the device uses its built-in curve, which is left to the defaults. -Writing creates, by default, the import archive in the USB layout: copy the *px* folder onto a USB drive which was formatted by the device, insert it into the sample import port and import the user bank from the Global menu of the device. The category is the closest one of the 17 categories of the device to the category of the source, unless one is selected. The samples are written as 16 bit / 48 kHz WAV files: the device ignores the sample rate of a file and plays everything at 48 kHz, so any other rate would sound transposed. A zone with a play range is cut to it, since the format has no play range. Only the first loop of a zone is written and always as a forward loop; a backwards or alternating loop and reversed playback are reported. Round robin and random zones get their number in their set and the device picks one of them at random; zones which trigger on release are skipped. The velocity response is written to *Volume.txt* if all zones share one which differs from the default of full, linear velocity. The mapping utility of the device allows at most 128 samples per instrument, more are reported but written. The device identifies an instrument by a UUID as well as by its name; the UUID is derived from the bank, the category and the name, so a source which is converted again gives the same instrument. Envelopes, filter and LFO settings are not part of an instrument on the device but of the program which plays it, so they are not written. When a Prophet X instrument is converted to a Prophet X instrument again, enable the flat output, otherwise the *px* folder of the source is recreated below the destination folder and the new *px* folder lands inside it. Written instruments are not yet verified on hardware. +Writing creates, by default, the import archive in the USB layout: copy the *px* folder onto a USB drive which was formatted by the device, insert it into the sample import port, set *Update Library* in the Global menu to *User* and press *Update Now*. The folder *u00* is the user bank 1 on the device, *u07* the bank 8. Keys which no zone covers are mapped to an empty sample, as PXToolkit does, since the device otherwise plays the nearest zone on them. Names are reduced to ASCII, which is all the display of the device shows, and the sample files carry only their format and data chunk. The category is the closest one of the 17 categories of the device to the category of the source, unless one is selected. The samples are written as 16 bit / 48 kHz WAV files: the device ignores the sample rate of a file and plays everything at 48 kHz, so any other rate would sound transposed. A zone with a play range is cut to it, since the format has no play range. Only the first loop of a zone is written and always as a forward loop; a backwards or alternating loop and reversed playback are reported. Round robin and random zones get their number in their set and the device picks one of them at random; zones which trigger on release are skipped. The velocity response is written to *Volume.txt* if all zones share one which differs from the default of full, linear velocity. The mapping utility of the device allows at most 128 samples per instrument, more are reported but written. The device identifies an instrument by a UUID as well as by its name; the UUID is derived from the bank, the category and the name, so a source which is converted again gives the same instrument. Envelopes, filter and LFO settings are not part of an instrument on the device but of the program which plays it, so they are not written. When a Prophet X instrument is converted to a Prophet X instrument again, enable the flat output, otherwise the *px* folder of the source is recreated below the destination folder and the new *px* folder lands inside it. Reading was verified on the archives of a third-party sample pack; the written archives follow the conventions of PXToolkit, whose archives play on the device, but are not yet verified on hardware themselves. ### Destination Options diff --git a/documentation/design/PROPHET_X_GRP_FORMAT.md b/documentation/design/PROPHET_X_GRP_FORMAT.md index ca4e62a4..05954702 100644 --- a/documentation/design/PROPHET_X_GRP_FORMAT.md +++ b/documentation/design/PROPHET_X_GRP_FORMAT.md @@ -10,9 +10,13 @@ sits in `CSampleOsc::PlayNote`. Sequential does not document the format; the off Series Mapping Utility" (8Dio) is the only public writer and states 16 bit / 48 kHz WAV, at most 128 files and 1.5 GB per instrument. -No factory content was available, so everything below comes from the code paths which read and -import the files. Statements about what the device *does* with a value are taken from the code; -the written output of ConvertWithMoss has **not** been verified on hardware yet. +No factory content was available, so the format below comes from the code paths which read and +import the files; statements about what the device *does* with a value are taken from the code. +Two other writers served as the reference for what actually plays: the free **PXToolkit** 1.3.2 +(ThinkerSnacks, an Electron app whose writer `ProphetWriter.js` is plain JavaScript) and the free +third-party pack *Goldbaby GBPX Free1* (six instruments in the USB layout, made with PXToolkit), +which ConvertWithMoss reads. The written output of ConvertWithMoss follows their conventions but +has **not** been verified on hardware yet. ## What kind of instrument this is @@ -166,6 +170,36 @@ the model (depth 1, linear), so that an unspecified source keeps the built-in ta * Any sample rate is accepted but ignored, see above: use 48 kHz. * The frame count is `data size / 2 / channels`. +## What the other writers do + +PXToolkit (`ProphetWriter.js`) and the Goldbaby pack show the conventions which are known to work: + +* The group file has only ten columns - `File Path`, `Low Midi Note`, `High Midi Note`, + `Low Velocity`, `High Velocity`, `Loop Start`, `Loop End`, `Round Robin Number`, + `Pitch in Hertz`, `Mono Collapse` - with LF line ends; the other five columns of the firmware are + never written. Every row carries a round robin number (1 for a single sample), the lowest + velocity is 1 and the pitch is written with full double precision. +* Keys which no zone covers are mapped to `silence.wav`, a 44-byte WAV with a `data` chunk of + zero bytes, so that the device plays nothing on them instead of the nearest zone. The zones of + the Goldbaby pack are instead stretched to 0 and 127. +* Loop points come from the `smpl` chunk of the source file and are passed on unchanged: the + looped instruments of the Goldbaby pack have `Loop End` equal to their frame count, i.e. one + frame beyond the file, which the device tolerates (it plays up to and including the frame at + `Loop End`). ConvertWithMoss clamps a loop end to the last frame when it writes. +* The WAV files are stripped to the `fmt ` and `data` chunks, 16 bit, and their header always + claims 48 kHz even when the audio is not converted: a file at another rate keeps its samples and + gets `Pitch in Hertz * 48000 / rate` instead, so the engine plays it slower and in tune. + ConvertWithMoss converts the audio to 48 kHz instead, which keeps the durations. +* An instrument name is `NN. First|Second`: a two-digit index which orders the instruments on the + device (`firstnum_strcmp`), then the two lines of the display name separated by `|`. The tool + rejects characters outside printable ASCII and `/ ? < > \ : * "`. Since `|` is not allowed in + a file name on Windows, the archive itself may be named with `_` in its place; macOS stores a + `|` on a FAT32 drive as U+F027, which the importer (`CorrectPipeInString`) maps back to `|`. +* The pack's guide states the import path on the device: Global menu, *34. Update Library* set to + *User*, then *Update Now*; the folder `u07` fills the user bank 8, so `u00` is bank 1. It also + warns that firmware before 2.1.0.0.0 can brick the device when samples are installed. +* The tool limits an instrument to 1.5 GB of samples and reads 16 or 24 bit WAV or AIFF. + ## What the instrument cannot carry: modulation Envelopes, the filter, the LFOs, the modulation matrix, glide, unison, pitch bend and the loop diff --git a/src/main/java/de/mossgrabers/convertwithmoss/format/sequential/prophetx/ProphetXCreator.java b/src/main/java/de/mossgrabers/convertwithmoss/format/sequential/prophetx/ProphetXCreator.java index 3e866f33..12bc3e11 100644 --- a/src/main/java/de/mossgrabers/convertwithmoss/format/sequential/prophetx/ProphetXCreator.java +++ b/src/main/java/de/mossgrabers/convertwithmoss/format/sequential/prophetx/ProphetXCreator.java @@ -9,12 +9,16 @@ import java.io.File; import java.io.FileOutputStream; import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; import java.nio.charset.StandardCharsets; import java.nio.file.Files; +import java.text.Normalizer; import java.util.ArrayList; import java.util.Comparator; import java.util.HashSet; import java.util.List; +import java.util.Locale; import java.util.Optional; import java.util.Set; import java.util.UUID; @@ -34,6 +38,9 @@ import de.mossgrabers.convertwithmoss.core.model.enumeration.LoopType; import de.mossgrabers.convertwithmoss.core.model.enumeration.PlayLogic; import de.mossgrabers.convertwithmoss.core.model.enumeration.TriggerType; +import de.mossgrabers.convertwithmoss.file.riff.CommonRiffChunkId; +import de.mossgrabers.convertwithmoss.file.wav.WaveFile; +import de.mossgrabers.convertwithmoss.file.wav.WaveRiffChunkId; import de.mossgrabers.tools.FileUtils; @@ -42,7 +49,8 @@ * contains its samples as WAV files and a group file (ending .grp) with the key, velocity * and loop mapping. By default the instrument is written as the archive which the device imports * from a USB drive: px/<bank>/<category>/<name>.zip. The format was - * reverse-engineered from the OS 2.2.2 firmware, see + * reverse-engineered from the OS 2.2.2 firmware and the output follows the conventions of the + * free PXToolkit, whose archives play on the device, see * documentation/design/PROPHET_X_GRP_FORMAT.md. * * @author Jürgen Moßgraber @@ -59,10 +67,11 @@ public class ProphetXCreator extends AbstractWavCreator }, ProphetXTag.SAMPLE_RATE, true); private static final String WAV_ENDING = ".wav"; - private static final String LINE_ENDING = "\r\n"; + private static final String LINE_ENDING = "\n"; private static final String FALLBACK_NAME = "Instrument"; private static final int DEFAULT_ROOT_KEY = 60; private static final int NUM_VELOCITIES = 128; + private static final int NUM_KEYS = 128; /** @@ -97,15 +106,47 @@ public void createPreset (final File destinationFolder, final IMultisampleSource // samples are converted; the audio of a loop and its positions are converted together this.recalculateSamplePositions (multisampleSource, ProphetXTag.SAMPLE_RATE); + // The device plays the nearest zone on a key which no zone covers, therefore such keys are + // mapped to an empty sample, like PXToolkit does + final List keyGaps = findKeyGaps (zones); + final String silenceName = keyGaps.isEmpty () ? null : this.createSilenceName (zones); + if (this.settingsConfiguration.isWriteArchive ()) - this.writeArchive (destinationFolder, multisampleSource, zones, category, bank); + this.writeArchive (destinationFolder, multisampleSource, zones, category, bank, keyGaps, silenceName); else - this.writeFolder (destinationFolder, multisampleSource, zones, category, bank); + this.writeFolder (destinationFolder, multisampleSource, zones, category, bank, keyGaps, silenceName); this.progress.notifyDone (); } + /** {@inheritDoc} */ + @Override + protected String createSampleFilename (final ISampleZone zone, final int zoneIndex, final String fileEnding) + { + // The device shows ASCII only + return SafeFileNames.create (toAscii (zone.getName ())) + fileEnding; + } + + + /** {@inheritDoc} */ + @Override + protected void additionalProcessing (final IMultisampleSource multisampleSource, final ISampleZone zone, final WaveFile wavFile) + { + super.additionalProcessing (multisampleSource, zone, wavFile); + + // The device reads only the format and the data chunk, and PXToolkit writes nothing else + // either; the chunks which the options above add on purpose are kept + wavFile.removeChunks (CommonRiffChunkId.LIST_ID, CommonRiffChunkId.JUNK_ID, CommonRiffChunkId.JUNK2_ID, WaveRiffChunkId.FILLER_ID, WaveRiffChunkId.MD5_ID, WaveRiffChunkId.FACT_ID, WaveRiffChunkId.CUE_ID, WaveRiffChunkId.PLST_ID, WaveRiffChunkId.LABL_ID, WaveRiffChunkId.NOTE_ID, WaveRiffChunkId.LTXT_ID, WaveRiffChunkId.ACID_ID, WaveRiffChunkId.META_ID, WaveRiffChunkId.ATEM_ID); + if (!this.settingsConfiguration.isUpdateBroadcastAudioChunk ()) + wavFile.removeChunks (WaveRiffChunkId.BEXT_ID); + if (!this.settingsConfiguration.isUpdateInstrumentChunk ()) + wavFile.removeChunks (WaveRiffChunkId.INST_ID); + if (!this.settingsConfiguration.isUpdateSampleChunk ()) + wavFile.removeChunks (WaveRiffChunkId.SMPL_ID); + } + + /** * Write the instrument as the archive which the device imports from a USB drive: * px/<bank>/<category>/<name>.zip. The archive contains the @@ -117,9 +158,11 @@ public void createPreset (final File destinationFolder, final IMultisampleSource * @param zones The zones to write * @param category The index of the category of the device * @param bank The folder name of the user bank + * @param keyGaps The key ranges which no zone covers + * @param silenceName The file name of the empty sample for the key gaps, null if there are none * @throws IOException Could not write the archive */ - private void writeArchive (final File destinationFolder, final IMultisampleSource multisampleSource, final List zones, final int category, final String bank) throws IOException + private void writeArchive (final File destinationFolder, final IMultisampleSource multisampleSource, final List zones, final int category, final String bank, final List keyGaps, final String silenceName) throws IOException { final File categoryFolder = new File (new File (new File (destinationFolder, ProphetXTag.USB_FOLDER), bank), ProphetXTag.CATEGORY_FOLDERS[category]); safeCreateDirectory (categoryFolder); @@ -150,9 +193,11 @@ private void writeArchive (final File destinationFolder, final IMultisampleSourc this.rewriteFile (multisampleSource, zone, outputStream, DESTINATION_FORMAT, true); zipDataFile (zipOutputStream, name + "/" + fileName, outputStream.toByteArray ()); } + if (silenceName != null) + zipDataFile (zipOutputStream, name + "/" + silenceName, createSilence ()); // Cutting the samples moved their loops, so the group file is created afterwards - zipTextFile (zipOutputStream, name + "/" + name + ProphetXTag.GROUP_FILE_ENDING, this.createGroupFile (zones, name, category, bank)); + zipTextFile (zipOutputStream, name + "/" + name + ProphetXTag.GROUP_FILE_ENDING, this.createGroupFile (zones, name, category, bank, keyGaps, silenceName)); final Optional velocityVolumes = createVelocityVolumes (zones); if (velocityVolumes.isPresent ()) zipTextFile (zipOutputStream, name + "/" + ProphetXTag.VOLUME_FILE, velocityVolumes.get ()); @@ -168,9 +213,11 @@ private void writeArchive (final File destinationFolder, final IMultisampleSourc * @param zones The zones to write * @param category The index of the category of the device * @param bank The folder name of the user bank + * @param keyGaps The key ranges which no zone covers + * @param silenceName The file name of the empty sample for the key gaps, null if there are none * @throws IOException Could not write the files */ - private void writeFolder (final File destinationFolder, final IMultisampleSource multisampleSource, final List zones, final int category, final String bank) throws IOException + private void writeFolder (final File destinationFolder, final IMultisampleSource multisampleSource, final List zones, final int category, final String bank, final List keyGaps, final String silenceName) throws IOException { final File instrumentFolder = this.createUniqueFilename (destinationFolder, createInstrumentName (multisampleSource), ""); if (!instrumentFolder.mkdirs ()) @@ -187,9 +234,11 @@ private void writeFolder (final File destinationFolder, final IMultisampleSource // The format has no play range, therefore the samples are cut to it this.writeSamples (instrumentFolder, multisampleSource, zones, WAV_ENDING, DESTINATION_FORMAT, true); + if (silenceName != null) + Files.write (new File (instrumentFolder, silenceName).toPath (), createSilence ()); // Cutting the samples moved their loops, so the group file is created afterwards - Files.write (groupFile.toPath (), this.createGroupFile (zones, name, category, bank).getBytes (StandardCharsets.UTF_8)); + Files.write (groupFile.toPath (), this.createGroupFile (zones, name, category, bank, keyGaps, silenceName).getBytes (StandardCharsets.UTF_8)); final Optional velocityVolumes = createVelocityVolumes (zones); if (velocityVolumes.isPresent ()) Files.write (new File (instrumentFolder, ProphetXTag.VOLUME_FILE).toPath (), velocityVolumes.get ().getBytes (StandardCharsets.UTF_8)); @@ -266,14 +315,15 @@ private int getCategory (final IMultisampleSource multisampleSource) /** * Create the name of the instrument from the name of the multi-sample. The firmware copies the * name into a buffer of 64 bytes, skips names which start with a dot and passes the name - * through a shell, so the characters which a shell expands are replaced. + * through a shell, so the characters which a shell expands are replaced; the display of the + * device shows ASCII only. * * @param multisampleSource The multi-sample * @return The name */ private static String createInstrumentName (final IMultisampleSource multisampleSource) { - String name = SafeFileNames.create (multisampleSource.getName ()).strip (); + String name = SafeFileNames.create (toAscii (multisampleSource.getName ())).strip (); while (name.startsWith (".") || name.endsWith (".")) name = (name.startsWith (".") ? name.substring (1) : name.substring (0, name.length () - 1)).strip (); if (name.length () > ProphetXTag.MAX_NAME_LENGTH) @@ -282,18 +332,117 @@ private static String createInstrumentName (final IMultisampleSource multisample } + /** + * Reduce a name to ASCII: accented letters lose their accent, every other character which is + * not printable ASCII becomes an underscore. + * + * @param name The name + * @return The ASCII name + */ + private static String toAscii (final String name) + { + final String decomposed = Normalizer.normalize (name, Normalizer.Form.NFD); + final StringBuilder ascii = new StringBuilder (decomposed.length ()); + for (int i = 0; i < decomposed.length (); i++) + { + final char c = decomposed.charAt (i); + // The accent of a decomposed letter + if (Character.getType (c) == Character.NON_SPACING_MARK) + continue; + ascii.append (c >= ' ' && c < 127 ? c : '_'); + } + return ascii.toString (); + } + + + /** + * Find the key ranges which no zone covers. + * + * @param zones The zones to write + * @return The gaps as pairs of the first and the last key + */ + private static List findKeyGaps (final List zones) + { + final boolean [] covered = new boolean [NUM_KEYS]; + for (final ISampleZone zone: zones) + { + final int keyLow = Math.clamp (zone.getKeyLow (), 0, NUM_KEYS - 1); + final int keyHigh = Math.clamp (limitToDefault (zone.getKeyHigh (), NUM_KEYS - 1), keyLow, NUM_KEYS - 1); + for (int key = keyLow; key <= keyHigh; key++) + covered[key] = true; + } + + final List gaps = new ArrayList<> (); + int start = -1; + // The key beyond the last one counts as covered, which closes a gap at the top + for (int key = 0; key <= NUM_KEYS; key++) + { + final boolean isCovered = key == NUM_KEYS || covered[key]; + if (!isCovered && start < 0) + start = key; + else if (isCovered && start >= 0) + { + gaps.add (new int [] + { + start, + key - 1 + }); + start = -1; + } + } + return gaps; + } + + + /** + * Create the file name of the empty sample, which must not collide with the sample of a zone. + * + * @param zones The zones to write + * @return The file name + */ + private String createSilenceName (final List zones) + { + final Set fileNames = new HashSet<> (); + for (int zoneIndex = 0; zoneIndex < zones.size (); zoneIndex++) + fileNames.add (this.createSampleFilename (zones.get (zoneIndex), zoneIndex, WAV_ENDING).toLowerCase (Locale.US)); + String silenceName = ProphetXTag.SILENCE_NAME + WAV_ENDING; + for (int counter = 2; fileNames.contains (silenceName.toLowerCase (Locale.US)); counter++) + silenceName = ProphetXTag.SILENCE_NAME + "_" + counter + WAV_ENDING; + return silenceName; + } + + + /** + * Create a WAV file without a single frame, the same one which PXToolkit ships: the device + * plays nothing on a key which maps to it. + * + * @return The content of the file + */ + private static byte [] createSilence () + { + final ByteBuffer buffer = ByteBuffer.allocate (44).order (ByteOrder.LITTLE_ENDIAN); + buffer.put ("RIFF".getBytes (StandardCharsets.US_ASCII)).putInt (36).put ("WAVE".getBytes (StandardCharsets.US_ASCII)); + buffer.put ("fmt ".getBytes (StandardCharsets.US_ASCII)).putInt (16); + buffer.putShort ((short) 1).putShort ((short) 1).putInt (ProphetXTag.SAMPLE_RATE).putInt (ProphetXTag.SAMPLE_RATE * 2).putShort ((short) 2).putShort ((short) ProphetXTag.BIT_RESOLUTION); + buffer.put ("data".getBytes (StandardCharsets.US_ASCII)).putInt (0); + return buffer.array (); + } + + /** * Create the text of the group file: a header line with the column names and one line per - * zone, separated by tabs. + * zone, separated by tabs, plus one line per key gap which maps it to the empty sample. * * @param zones The zones to write * @param name The name of the instrument * @param category The index of the category of the device * @param bank The folder name of the user bank + * @param keyGaps The key ranges which no zone covers + * @param silenceName The file name of the empty sample for the key gaps, null if there are none * @return The text * @throws IOException Could not read the metadata of a sample */ - private String createGroupFile (final List zones, final String name, final int category, final String bank) throws IOException + private String createGroupFile (final List zones, final String name, final int category, final String bank, final List keyGaps, final String silenceName) throws IOException { final String categoryName = ProphetXTag.CATEGORY_NAMES[category]; // The device identifies an instrument by its UUID and falls back to its name, therefore @@ -304,6 +453,8 @@ private String createGroupFile (final List zones, final String name final List rows = new ArrayList<> (); for (int zoneIndex = 0; zoneIndex < zones.size (); zoneIndex++) rows.add (this.createRow (zones.get (zoneIndex), zoneIndex, categoryName, name, uuid)); + for (final int [] keyGap: keyGaps) + rows.add (createSilenceRow (silenceName, keyGap[0], keyGap[1], categoryName, name, uuid)); // The device sorts the rows the same way, this only keeps the file readable rows.sort (Comparator.comparingInt ((final String [] row) -> Integer.parseInt (row[1])).thenComparingInt (row -> Integer.parseInt (row[3])).thenComparingInt (row -> Integer.parseInt (row[7]))); @@ -385,6 +536,40 @@ private String createGroupFile (final List zones, final String name } + /** + * Create the values of a row which maps a key gap to the empty sample. + * + * @param silenceName The file name of the empty sample + * @param keyLow The first key of the gap + * @param keyHigh The last key of the gap + * @param categoryName The name of the category of the device + * @param name The name of the instrument + * @param uuid The unique identifier of the instrument + * @return The values in the order of the columns + */ + private static String [] createSilenceRow (final String silenceName, final int keyLow, final int keyHigh, final String categoryName, final String name, final String uuid) + { + return new String [] + { + silenceName, + Integer.toString (keyLow), + Integer.toString (keyHigh), + "0", + "127", + Integer.toString (ProphetXTag.NOT_SET), + Integer.toString (ProphetXTag.NOT_SET), + "1", + formatDouble (ProphetXTag.DEFAULT_PITCH, 4), + ProphetXTag.NO, + ProphetXTag.MONO, + categoryName, + name, + ProphetXTag.COLLAPSE_BOTH, + uuid + }; + } + + /** * Get the number of frames which the written sample has: the play range of the zone if one is * set, otherwise the whole sample. diff --git a/src/main/java/de/mossgrabers/convertwithmoss/format/sequential/prophetx/ProphetXDetector.java b/src/main/java/de/mossgrabers/convertwithmoss/format/sequential/prophetx/ProphetXDetector.java index 33e38cb8..6c1476a1 100644 --- a/src/main/java/de/mossgrabers/convertwithmoss/format/sequential/prophetx/ProphetXDetector.java +++ b/src/main/java/de/mossgrabers/convertwithmoss/format/sequential/prophetx/ProphetXDetector.java @@ -235,6 +235,7 @@ private List parseGroupFile (final File sourceFile, final St // which is played in turn; the samples without a number form the first group final Map groups = new TreeMap<> (); int columnCategory = -1; + int emptySamples = 0; String instrumentName = null; for (final Map row: rows) { @@ -251,10 +252,26 @@ private List parseGroupFile (final File sourceFile, final St if (instrumentName == null) instrumentName = row.get (ProphetXTag.COLUMN_INSTRUMENT); - final ISampleData sampleData = folder.loadSample (filePath); - if (sampleData == null) + final ISampleZone zone; + try + { + final ISampleData sampleData = folder.loadSample (filePath); + if (sampleData == null) + continue; + // PXToolkit maps the keys which no zone covers to a sample without a single frame, + // so that the device plays nothing on them + if (sampleData.getAudioMetadata ().getNumberOfSamples () == 0) + { + emptySamples++; + continue; + } + zone = createZone (filePath, sampleData, row); + } + catch (final IOException ex) + { + this.notifier.logError ("IDS_NOTIFY_ERR_LOAD_FILE", ex); continue; - final ISampleZone zone = createZone (filePath, sampleData, row); + } // The firmware reads 0 as the first number of a set int roundRobin = getInt (row, ProphetXTag.COLUMN_ROUND_ROBIN, ProphetXTag.NOT_SET); @@ -269,6 +286,8 @@ private List parseGroupFile (final File sourceFile, final St groups.computeIfAbsent (groupNumber, number -> new DefaultGroup (number.intValue () == 0 ? "Group 1" : "Round Robin " + number)).addSampleZone (zone); } + if (emptySamples > 0) + this.notifier.log ("IDS_PROPHETX_EMPTY_SAMPLES", Integer.toString (emptySamples)); if (groups.isEmpty ()) { this.notifier.logError ("IDS_PROPHETX_NO_SAMPLES", sourceFile.getAbsolutePath ()); diff --git a/src/main/java/de/mossgrabers/convertwithmoss/format/sequential/prophetx/ProphetXTag.java b/src/main/java/de/mossgrabers/convertwithmoss/format/sequential/prophetx/ProphetXTag.java index 1683a4e9..54780b7b 100644 --- a/src/main/java/de/mossgrabers/convertwithmoss/format/sequential/prophetx/ProphetXTag.java +++ b/src/main/java/de/mossgrabers/convertwithmoss/format/sequential/prophetx/ProphetXTag.java @@ -32,6 +32,8 @@ public class ProphetXTag public static final int NUM_USER_BANKS = 32; /** The optional file next to the group file with one gain factor per velocity. */ public static final String VOLUME_FILE = "Volume.txt"; + /** The name of the empty sample which keeps the keys silent that no zone covers. */ + public static final String SILENCE_NAME = "silence"; /** The sample rate of the audio engine. The device ignores the sample rate of a WAV file. */ public static final int SAMPLE_RATE = 48000; diff --git a/src/main/resources/Strings.properties b/src/main/resources/Strings.properties index 71dbfd22..e2824571 100644 --- a/src/main/resources/Strings.properties +++ b/src/main/resources/Strings.properties @@ -506,6 +506,7 @@ IDS_PROPHETX_WRITE_ARCHIVE=Write the import archive IDS_PROPHETX_NOT_AN_ARCHIVE=Skipped '%1': the archive contains no Prophet X group file.\n IDS_PROPHETX_NO_HEADER=The group file '%1' has no header line which names its columns.\n IDS_PROPHETX_NO_SAMPLES=The group file '%1' references no samples which could be read.\n +IDS_PROPHETX_EMPTY_SAMPLES=%1 zone(s) map to an empty sample, which only keeps unmapped keys silent on the device, and are skipped.\n IDS_PROPHETX_VOLUME_FILE=Could not read the velocity volumes '%1': %2\n IDS_PROPHETX_UNSUPPORTED_CHANNELS=The device plays mono or stereo samples only; the sample '%1' has %2 channels and is skipped.\n IDS_PROPHETX_TOO_MANY_SAMPLES=The instrument has %1 samples, the mapping utility of the device allows at most %2.\n