diff --git a/documentation/CHANGELOG.md b/documentation/CHANGELOG.md index 8d17eafa..ae9d1922 100644 --- a/documentation/CHANGELOG.md +++ b/documentation/CHANGELOG.md @@ -1,5 +1,9 @@ # Changes +## 19.2.0 + +* New: Added support for the Roland SP-404MK2 format (reading and writing of projects; each bank of pads becomes a multi-sample). + ## 19.1.0 * Many thanks to Douglas Carmichael for plenty of contributions and fixes! diff --git a/documentation/README-FORMATS.md b/documentation/README-FORMATS.md index f177a1bf..42968c52 100644 --- a/documentation/README-FORMATS.md +++ b/documentation/README-FORMATS.md @@ -76,6 +76,7 @@ The following multi-sample formats are supported: * [Roland MV-8000/MV-8800](#roland-mv-8000mv-8800) * [Roland S-50 Series](#roland-s-50-series) - read only * [Roland S-770 Series](#roland-s-770-series) - read only +* [Roland SP-404MK2](#roland-sp-404mk2) * [Roland ZEN-Core](#roland-zen-core) * [Sample files (AIFF, FLAC, NCW, OGG, WAV)](#sample-files-aiff-flac-ncw-ogg-wav) * [SFZ](#sfz) @@ -559,6 +560,18 @@ The Roland S-770 series comprises a family of digital PCM samplers introduced be Only reading is supported. But it supports both HD/CD-Rom and diskette image files. Also files that span multiple diskettes are supported (all disk files need to be in the same folder). +## Roland SP-404MK2 + +The Roland SP-404MKII is a pad-based sampler and effector. A project holds 10 banks (A-J) of 16 pads each (160 pads); every pad plays a single sample with its own start/end, loop, level, pan, pitch and BPM. It is not a keyboard multi-sampler - there are no key-ranges - so, as with the other pad devices, ConvertWithMoss treats each **populated bank as one multi-sample** and each **pad as a single-key zone** (a drum-kit-shaped mapping). + +The format is not documented by Roland; it was reverse-engineered and validated against real exported projects and the SP-404MKII v5.52 firmware (see *documentation/design/SP404MK2_FORMAT.md*). A project is a folder (`PROJECT_XX`) holding a `PADCONF.BIN` pad-configuration file and a `SMPL` folder of `BANK-.SMP` samples in Roland's own `RFWV` wave container (48 kHz, 16-bit, big-endian PCM). Both the exported (31,488-byte) and the device-internal (52,000-byte) `PADCONF.BIN` forms are read. Point ConvertWithMoss at a project folder (or a folder of projects, e.g. the `EXPORT/PROJECT` folder of an SD card) to read it. Writing produces the same `PROJECT_XX/PADCONF.BIN` + `SMPL/*.SMP` layout; copy it into the `IMPORT` folder of an SD card and load it with the device's *IMPORT PROJECT* function. Written projects have not been verified on hardware yet - feedback is welcome. + +Reading extracts each pad's full sample together with its name, level, panning and loop on/off state; a multi-zone source written back becomes one pad per zone. The following limitations apply: + +* Each pad plays one sample, so overlapping velocity layers are reduced to the loudest layer and a bank holds at most 16 zones; zones beyond a bank are carried into the next bank. +* The on-device start/end trim and loop points are preserved in the file but not applied - the full sample is always extracted (the byte-offset encoding of the trim window is not yet fully decoded). +* Patterns (`PTN`), pattern chains and project artwork are not converted. + ## Roland ZEN-Core The ZEN-Core sound engine powers Roland's FANTOM-0, FANTOM / FANTOM EX, Juno-X, Jupiter-X/Xm and the MC-707/MC-101 grooveboxes, which all share one *.svz* container - and one model tag (`KY019`) - holding a tone (or a bank of tones) together with its user samples and its keyboard mapping. ConvertWithMoss writes an importable *.svz* - a single tone for one multi-sample, or a multi-tone bank that shares one sample pool for several - which is loaded on the device through its *UTILITY -> IMPORT* function. The GAIA-2 and the ZENOLOGY plug-in run the same engine but are not supported, because neither can load the user samples a multi-sample needs: the GAIA-2 has no sampler at all (its *IMPORT* accepts only tones) and the ZENOLOGY plug-in imports only the tone from a *.svz*, so a multi-sample would play silent there. The user samples, multisample key map, filter and envelopes of a *.svz* are also read back (the envelope times through the same hardware-calibrated law the writer uses). diff --git a/documentation/design/SP404MK2_FORMAT.md b/documentation/design/SP404MK2_FORMAT.md new file mode 100644 index 00000000..418cc63d --- /dev/null +++ b/documentation/design/SP404MK2_FORMAT.md @@ -0,0 +1,158 @@ +# Roland SP-404MK2 project / PADCONF.BIN format — reverse-engineered + +Reverse-engineered 2026-07 and **validated against a real exported project** (one of the +public projects from `sp404.amirmeludah.com`, system program v5.x) plus the +**SP-404MKII system program v5.52** firmware image (`SP404MKII_APP1.bin`, an uncompressed +ARM Thumb image loaded at `0x80000000` — i.MX RT1060 SDRAM). Cross-checked against three +independent community efforts: + +* `gsterlin/sp404mk2-tools` — a bit-level `PADCONF.BIN` diff log (firmware 5.01). +* `ciscoittech/sp404mk2-sample-agent` — a Python read/write reference implementation. +* `macdigi/compa` — protocol notes documenting the `RFPD` / `RFWV` magics. + +Fields below are confirmed against the real file unless marked **(?)** = still unknown. + +## What kind of instrument this is + +The SP-404MK2 is a **pad sampler**, not a keyboard multi-sampler — there is no +keygroup / key-range / per-zone-root-key model in the firmware. A **project** owns +10 **banks** (A–J), each bank has 16 **pads** (160 pads/project), 16 projects on the +device. Each pad holds **one** sample plus per-pad playback parameters. CHROMATIC mode +pitches a *single* pad's sample across a keyboard. + +**ConvertWithMoss mapping.** Same as CWM's other pad/drum devices (Tonverk Drum Kit, +MC-707 kits): **one populated bank → one multi-sample source**, **each pad → one +single-key zone**. A general keyboard multi-sample written *to* the MK2 is flattened to +one sample per pad. + +## On-card / exported project layout (confirmed) + +An exported project (`EXPORT PROJECT`, or the on-device `B:/ROLAND/SP-404MKII/PROJECT_%02d`) +is a self-contained folder: + +``` +PROJECT_XX/ + PADCONF.BIN pad configuration (RFPD, 31,488 bytes) <-- parsed + SMPL/ + BANK-

.SMP one sample per used pad (RFWV audio) <-- parsed + PTN/ PTN%05d.BIN, PATTERNCHAIN_%02d.CHN sequencer data (ignored) + PICTURE/ *.bmp project artwork (ignored) +``` + +`SMPL/BANK-

.SMP` naming: for global pad index `i` (0-based, 0..159), +`b = i/16 + 1` (1..10, bank A..J) and `p = i%16 + 1` (1..16). E.g. pad index 16 +(bank B, pad 1) → `BANK2-01.SMP`. Only used pads have a file. + +## `SMPL/*.SMP` — RFWV audio (confirmed) + +Roland's own wave container. All integers **big-endian**; **PCM samples are also +big-endian** (verified: byte-swapped interpretation has ~27× the sample-to-sample +delta energy). + +``` +offset size content +0 4 "RFWV" magic +4 4 u32 file size - 8 (RIFF-style) +8 4 u32 sample rate (48000) +12 4 u32 channel count (1 = mono, 2 = stereo) +16 4 u32 bit depth (16) +20 12 reserved (zero) +32 ... PCM, interleaved, signed 16-bit BIG-endian +``` + +PCM frame count = `(fileSize - 32) / (channels * 2)`. + +## `PADCONF.BIN` — pad configuration (confirmed) + +Two forms exist and **both are read**; the **export** form is what CWM writes (it is the +importable one): + +| form | size | version (@0x08) | header | pad-metadata | pad-names | trailer | +|----------|--------|-----------------|--------|--------------|-----------|---------| +| export | 31,488 | 2 | 0x80 | @0x80 | @0x6C00 | — | +| internal | 52,000 | 3 | 0xA0 | @0xA0 | @0x6C20 | 20,480-byte per-pad "marks" section | + +The two differ only in the header size (0x80 vs 0xA0, which shifts the metadata and name +blocks) and the trailing marks section; the 160×172 metadata records and 160×24 names are +byte-for-byte identical between the forms (verified). The name-block offset is therefore +`headerSize + 160×172` and the metadata starts at `headerSize`. The description below uses +the export offsets. + +### Header (0x00–0x7F) + +``` +0x00 4 "RFPD" magic +0x04 4 u32 pad count = 0x000000A0 = 160 +0x08 4 u32 version (0x02000000) +0x0C 4 u32 pad-data region size = 0x7A80 = 31360 (= 160*172 + 160*24) +0x10 4 u32 (?) 0x000124B8 in sample file +0x40 40 10 × u32 per-bank BPM, stored as BPM*200 (0x4650=18000 → 90.00 BPM) +0x68 .. per-bank volumes (?) / global params +``` +Project display name is taken from the folder name (the export header carries no name +at a confirmed offset). + +### Pad metadata — 160 records of 172 bytes, starting at **0x80** + +Record for pad index `i` at `0x80 + i*172`. All fields u32 big-endian unless noted. +Confirmed against real trimmed + looped + full pads: + +``` ++0x00 sample end (byte offset into the .SMP incl. its 32-byte header; == file size + for an untrimmed pad) ++0x04 sample start (byte offset; 512 used as the "from the beginning" sentinel) ++0x08 sample end (duplicate of +0x00 — orig/user end pair) ++0x0C volume (0..127) ++0x10 loop enable (0 = off, 1 = on) ++0x18 (?) 1 when the pad is used ++0x24 pad BPM * 100 (9000 = 90.00) ++0x2C loop start (byte offset; == +0x04 for a full pad) ++0x30 pitch, semitones (signed) ++0x34 fine tune (signed) ++0x48 pan (0x40 = center, <0x40 left, >0x40 right) + remaining bytes: envelope / filter / routing — not yet pinned (?) +``` + +### Filenames — 160 records of 24 bytes, starting at **0x6C00** + +Record `i` at `0x6C00 + i*24`: up to 23 chars, then a NUL, **space-padded** to 24 +(an all-space/empty field means the pad is unused). This is the pad's display name +(e.g. `01-New Sample`); the *audio* is resolved by the `BANK-

.SMP` convention +above, not by this string. + +### Start/end trim + loop points — partially decoded + +Analyzed against the real project's audio (all 47 pads): + +* `+0x04` is the **play start**, `+0x2C` the **loop start** (only meaningful when + `+0x10` loop = 1); `+0x00` = `+0x08` = the sample **data length** (= the `.SMP` file + size, so `(end - 32) / bytesPerFrame` = total frames). All are **byte offsets**, + `bytesPerFrame = channels * 2` (4 for stereo). +* An **untrimmed** pad stores **512** for start (and loop start). Trimmed pads store a + larger offset and play a sub-window; e.g. `BANK2-03` stores start = 23032 and plays + from ≈ frame 5630 to the end, which lines up with where its waveform's leading + transient stops (the audio before that frame is trimmed off). +* First-order formula: `startFrame ≈ (start - 512) / bytesPerFrame` (clamped ≥ 0), + `loopStartFrame ≈ (loopStart - 512) / bytesPerFrame`, `endFrame = total frames` + (no end-trimmed pad appears in the one available project). + +**Unresolved — the exact base (a 2.5 ms / 120-frame swing).** `(v - 512)` maps every +untrimmed pad to frame 0 (correct for a drum hit whose attack is on the first sample, +e.g. `BANK2-01`), but a few pads argue for `(v - 32)` instead — `BANK4-05`'s quiet +intro ends *exactly* at `(2048 - 32) / 4 = frame 504`, and some long/looped pads use +2048 / 6048 as their "start". The inconsistency implies a subtler rule (a minimum-start +floor, or an import auto-trim of leading silence) that one project cannot pin down, and +the end-trim base is untested (no end-trimmed pad in the corpus). + +**Therefore v1 still extracts the full sample** (start = 0, end = total frames) — safe +and lossless. Applying the start-trim is a one-line change once the base is confirmed, +by either: an on-device **edit-diff** (set a known start/end/loop, export, diff the +`PADCONF.BIN` — the method used for the other Roland formats), or a firmware-loader +disassembly (image base `0x80000000`). Issue #215's author owns the hardware and is +reverse-engineering the same format — the fastest route to certainty. + +## Firmware-confirmed folder tokens + +`B:/ROLAND/SP-404MKII/PROJECT_%02d`, `/SMPL/`, `/PADCONF.BIN`, +`A:/EXPORT/PROJECT/PROJECT_%02d`, `A:/IMPORT/…`, plus `CHROMATIC` / `16 VELOCITY` / +`MULTIPAD` mode strings — none of which imply a keyboard multi-sample model. diff --git a/src/main/java/de/mossgrabers/convertwithmoss/core/ConverterBackend.java b/src/main/java/de/mossgrabers/convertwithmoss/core/ConverterBackend.java index 71a8c8d5..174bcac5 100644 --- a/src/main/java/de/mossgrabers/convertwithmoss/core/ConverterBackend.java +++ b/src/main/java/de/mossgrabers/convertwithmoss/core/ConverterBackend.java @@ -82,6 +82,8 @@ import de.mossgrabers.convertwithmoss.format.roland.mv8000.MV8000Detector; import de.mossgrabers.convertwithmoss.format.roland.s5xx.S5xxDetector; import de.mossgrabers.convertwithmoss.format.roland.s7xx.S770Detector; +import de.mossgrabers.convertwithmoss.format.roland.sp404mk2.SP404Mk2Creator; +import de.mossgrabers.convertwithmoss.format.roland.sp404mk2.SP404Mk2Detector; import de.mossgrabers.convertwithmoss.format.roland.zencore.ZenCoreCreator; import de.mossgrabers.convertwithmoss.format.roland.zencore.ZenCoreDetector; import de.mossgrabers.convertwithmoss.format.samplefile.SampleFileDetector; @@ -177,6 +179,7 @@ public ConverterBackend (final INotifier notifier) this.detectors.add (new MV8000Detector (notifier)); this.detectors.add (new S5xxDetector (notifier)); this.detectors.add (new S770Detector (notifier)); + this.detectors.add (new SP404Mk2Detector (notifier)); this.detectors.add (new ZenCoreDetector (notifier)); this.detectors.add (new SxtDetector (notifier)); this.detectors.add (new SampleFileDetector (notifier)); @@ -211,6 +214,7 @@ public ConverterBackend (final INotifier notifier) this.creators.add (new RenoiseCreator (notifier)); this.creators.add (new MC707Creator (notifier)); this.creators.add (new MV8000Creator (notifier)); + this.creators.add (new SP404Mk2Creator (notifier)); this.creators.add (new ZenCoreCreator (notifier)); this.creators.add (new SxtCreator (notifier)); this.creators.add (new WavCreator (notifier)); diff --git a/src/main/java/de/mossgrabers/convertwithmoss/format/roland/sp404mk2/SP404Mk2Constants.java b/src/main/java/de/mossgrabers/convertwithmoss/format/roland/sp404mk2/SP404Mk2Constants.java new file mode 100644 index 00000000..2aa52115 --- /dev/null +++ b/src/main/java/de/mossgrabers/convertwithmoss/format/roland/sp404mk2/SP404Mk2Constants.java @@ -0,0 +1,219 @@ +// 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.roland.sp404mk2; + +/** + * Constants and low-level byte helpers for the Roland SP-404MK2 project format. The pad + * configuration (PADCONF.BIN, magic RFPD) and the sample files + * (SMPL/BANK<bank>-<pad>.SMP, magic RFWV) were reverse-engineered and + * validated against real exported projects and the SP-404MK2 v5.52 firmware (see + * documentation/design/SP404MK2_FORMAT.md). All multi-byte integers - and the audio PCM + * itself - are stored big-endian. + * + * @author Jürgen Moßgraber + */ +public final class SP404Mk2Constants +{ + /** The device native sample rate. */ + public static final int SAMPLE_RATE = 48000; + /** Number of banks (A-J) in a project. */ + public static final int BANK_COUNT = 10; + /** Number of pads per bank. */ + public static final int PADS_PER_BANK = 16; + /** Total number of pads in a project. */ + public static final int PAD_COUNT = BANK_COUNT * PADS_PER_BANK; + + // PADCONF.BIN layout + /** The fixed file name of the pad configuration of a project. */ + public static final String PADCONF_FILE_NAME = "PADCONF.BIN"; + /** The lower-case file name the detector scans for (see {@link #PADCONF_FILE_NAME}). */ + public static final String PADCONF_ENDING = "padconf.bin"; + /** The ending of the sample files, e.g. SMPL/BANK1-01.SMP. */ + public static final String SAMPLE_ENDING = ".smp"; + /** The magic bytes of a pad configuration file. */ + public static final byte [] RFPD_MAGIC = { 'R', 'F', 'P', 'D' }; + /** The size of an exported PADCONF.BIN (EXPORT/IMPORT PROJECT form). */ + public static final int PADCONF_SIZE_EXPORT = 31488; + /** The size of an internal-storage PADCONF.BIN (has a larger header and a trailing marks section). */ + public static final int PADCONF_SIZE_INTERNAL = 52000; + /** Header size of the export form; the pad-metadata block starts right after the header. */ + public static final int HEADER_SIZE_EXPORT = 0x80; + /** Header size of the internal form. */ + public static final int HEADER_SIZE_INTERNAL = 0xA0; + /** Size of one pad-metadata record. */ + public static final int PAD_RECORD_SIZE = 172; + /** Size of one pad-name record. */ + public static final int NAME_SIZE = 24; + /** Maximum number of characters in a pad name (the 24th byte is the terminator). */ + public static final int NAME_MAX_CHARS = 23; + + // PADCONF.BIN header fields + /** Header: number of pads (always 160). */ + public static final int HDR_PAD_COUNT = 0x04; + /** Header: format version. */ + public static final int HDR_VERSION = 0x08; + /** Header: size of the pad-data region (metadata + names). */ + public static final int HDR_DATA_SIZE = 0x0C; + /** Header: first of the 10 per-bank tempo values (BPM * 200). */ + public static final int HDR_BANK_BPM = 0x40; + + // Pad-metadata record fields (relative to the record start) + /** Sample end as a byte offset into the .SMP file (equals the file size for a full pad). */ + public static final int PAD_END = 0x00; + /** Sample start as a byte offset; {@link #START_SENTINEL} means "from the beginning". */ + public static final int PAD_START = 0x04; + /** Duplicate of the sample end (original / user end pair). */ + public static final int PAD_END2 = 0x08; + /** Playback level, 0-127. */ + public static final int PAD_VOLUME = 0x0C; + /** Loop enable, 0 or 1. */ + public static final int PAD_LOOP = 0x10; + /** Constant 1 for every pad record. */ + public static final int PAD_USED = 0x18; + /** Pad tempo, BPM * 100. */ + public static final int PAD_BPM = 0x24; + /** Loop start as a byte offset. */ + public static final int PAD_LOOP_START = 0x2C; + /** Coarse pitch in semitones (signed). */ + public static final int PAD_PITCH = 0x30; + /** Fine pitch in cents (signed). */ + public static final int PAD_FINE = 0x34; + /** Playback speed, 10000 = 100%. */ + public static final int PAD_SPEED = 0x40; + /** Panning, 0x40 = center. */ + public static final int PAD_PAN = 0x48; + /** The byte-offset value used to mean "start at the very beginning". */ + public static final int START_SENTINEL = 512; + /** Center value of the panning field. */ + public static final int PAN_CENTER = 0x40; + + // RFWV sample file layout + /** The magic bytes of a sample file. */ + public static final byte [] RFWV_MAGIC = { 'R', 'F', 'W', 'V' }; + /** Size of the RFWV header; PCM data follows. */ + public static final int RFWV_HEADER_SIZE = 32; + /** RFWV header: sample rate. */ + public static final int RFWV_RATE = 0x08; + /** RFWV header: number of channels. */ + public static final int RFWV_CHANNELS = 0x0C; + /** RFWV header: bit resolution. */ + public static final int RFWV_BITS = 0x10; + + + /** + * Constructor. Private due to only static usage. + */ + private SP404Mk2Constants () + { + // Intentionally empty + } + + + /** + * The header size for a PADCONF.BIN of the given length, or -1 if the length is not a + * recognized PADCONF.BIN. The pad-metadata block starts right after the header. + * + * @param length The file length + * @return The header size (which equals the pad-metadata start), or -1 + */ + public static int headerSizeForLength (final int length) + { + if (length == PADCONF_SIZE_EXPORT) + return HEADER_SIZE_EXPORT; + if (length == PADCONF_SIZE_INTERNAL) + return HEADER_SIZE_INTERNAL; + return -1; + } + + + /** + * The offset of the pad-name block, which follows the header and the pad-metadata block. + * + * @param headerSize The header size (see {@link #headerSizeForLength(int)}) + * @return The offset of the first pad name + */ + public static int nameBlockStart (final int headerSize) + { + return headerSize + PAD_COUNT * PAD_RECORD_SIZE; + } + + + /** + * Test if the given data starts with the given magic bytes. + * + * @param data The data + * @param magic The magic bytes + * @return True if the data starts with the magic bytes + */ + public static boolean hasMagic (final byte [] data, final byte [] magic) + { + if (data.length < magic.length) + return false; + for (int i = 0; i < magic.length; i++) + if (data[i] != magic[i]) + return false; + return true; + } + + + /** + * Read an unsigned 32-bit big-endian integer. + * + * @param data The data + * @param offset The offset to read from + * @return The value + */ + public static long getU32 (final byte [] data, final int offset) + { + return (data[offset] & 0xFFL) << 24 | (data[offset + 1] & 0xFFL) << 16 | (data[offset + 2] & 0xFFL) << 8 | data[offset + 3] & 0xFFL; + } + + + /** + * Read a signed 32-bit big-endian integer. + * + * @param data The data + * @param offset The offset to read from + * @return The value + */ + public static int getS32 (final byte [] data, final int offset) + { + return (int) getU32 (data, offset); + } + + + /** + * Write a 32-bit big-endian integer. + * + * @param data The data + * @param offset The offset to write to + * @param value The value + */ + public static void putU32 (final byte [] data, final int offset, final long value) + { + data[offset] = (byte) (value >> 24 & 0xFF); + data[offset + 1] = (byte) (value >> 16 & 0xFF); + data[offset + 2] = (byte) (value >> 8 & 0xFF); + data[offset + 3] = (byte) (value & 0xFF); + } + + + /** + * Swap the byte order of every 16-bit sample in place, converting between the big-endian PCM of + * the RFWV files and the little-endian PCM used everywhere else. The operation is its own + * inverse. + * + * @param pcm The interleaved 16-bit PCM data (its length must be even) + */ + public static void swap16 (final byte [] pcm) + { + for (int i = 0; i + 1 < pcm.length; i += 2) + { + final byte tmp = pcm[i]; + pcm[i] = pcm[i + 1]; + pcm[i + 1] = tmp; + } + } +} diff --git a/src/main/java/de/mossgrabers/convertwithmoss/format/roland/sp404mk2/SP404Mk2Creator.java b/src/main/java/de/mossgrabers/convertwithmoss/format/roland/sp404mk2/SP404Mk2Creator.java new file mode 100644 index 00000000..3eafcfcd --- /dev/null +++ b/src/main/java/de/mossgrabers/convertwithmoss/format/roland/sp404mk2/SP404Mk2Creator.java @@ -0,0 +1,350 @@ +// 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.roland.sp404mk2; + +import java.io.BufferedOutputStream; +import java.io.File; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import de.mossgrabers.convertwithmoss.core.IMultisampleSource; +import de.mossgrabers.convertwithmoss.core.INotifier; +import de.mossgrabers.convertwithmoss.core.creator.AbstractCreator; +import de.mossgrabers.convertwithmoss.core.creator.DestinationAudioFormat; +import de.mossgrabers.convertwithmoss.core.model.IGroup; +import de.mossgrabers.convertwithmoss.core.model.ISampleData; +import de.mossgrabers.convertwithmoss.core.model.ISampleZone; +import de.mossgrabers.convertwithmoss.core.settings.EmptySettingsUI; +import de.mossgrabers.convertwithmoss.file.AudioFileUtils; +import de.mossgrabers.convertwithmoss.file.wav.WaveFile; + + +/** + * Creator for Roland SP-404MK2 projects. A source's zones are laid onto the pads of a bank (16 per + * bank, overflowing into the next bank); a library of sources fills consecutive banks. The output + * is a project folder with a PADCONF.BIN pad configuration and a SMPL folder of RFWV + * BANK<bank>-<pad>.SMP samples (48 kHz / 16-bit). Copy the folder into the + * IMPORT folder of an SD card and load it with the device's IMPORT PROJECT function. + * Written projects have not been verified on hardware yet. Files round-trip through + * {@link SP404Mk2Detector}. + * + * @author Jürgen Moßgraber + */ +public class SP404Mk2Creator extends AbstractCreator +{ + private static final DestinationAudioFormat DESTINATION_FORMAT = new DestinationAudioFormat (new int [] + { + 16 + }, SP404Mk2Constants.SAMPLE_RATE, true); + + /** The creator always writes the export PADCONF.BIN form. */ + private static final int META_START = SP404Mk2Constants.HEADER_SIZE_EXPORT; + private static final int NAME_START = SP404Mk2Constants.nameBlockStart (SP404Mk2Constants.HEADER_SIZE_EXPORT); + + /** Default project / pad tempo (120.00 BPM) in the BPM * 100 unit. */ + private static final int DEFAULT_PAD_BPM = 12000; + /** Default per-bank tempo (120.00 BPM) in the BPM * 200 unit. */ + private static final int DEFAULT_BANK_BPM = 24000; + /** Playback speed for 100%. */ + private static final int SPEED_100 = 10000; + + + /** + * Constructor. + * + * @param notifier The notifier + */ + public SP404Mk2Creator (final INotifier notifier) + { + super ("Roland SP-404MK2", "SP404MK2", notifier, EmptySettingsUI.INSTANCE); + } + + + /** {@inheritDoc} */ + @Override + public void createPreset (final File destinationFolder, final IMultisampleSource multisampleSource) throws IOException + { + this.writeProject (destinationFolder, List.of (multisampleSource), multisampleSource.getName ()); + } + + + /** {@inheritDoc} */ + @Override + public boolean supportsPresetLibraries () + { + return true; + } + + + /** {@inheritDoc} */ + @Override + public void createPresetLibrary (final File destinationFolder, final List multisampleSources, final String libraryName) throws IOException + { + if (!multisampleSources.isEmpty ()) + this.writeProject (destinationFolder, multisampleSources, libraryName); + } + + + /** + * Write a project folder for the given sources. + * + * @param destinationFolder Where to create the project folder + * @param multisampleSources The sources to convert + * @param name The project name + * @throws IOException Could not write the project + */ + private void writeProject (final File destinationFolder, final List multisampleSources, final String name) throws IOException + { + final File projectFolder = new File (destinationFolder, createSafeFilename (name)); + safeCreateDirectory (projectFolder); + final File smplFolder = new File (projectFolder, "SMPL"); + safeCreateDirectory (smplFolder); + this.notifier.log ("IDS_SP404MK2_WRITING_PROJECT", projectFolder.getAbsolutePath ()); + + final byte [] padconf = new byte [SP404Mk2Constants.PADCONF_SIZE_EXPORT]; + writeHeader (padconf); + + int bank = 0; + int pad = 0; + int written = 0; + boolean full = false; + + for (final IMultisampleSource multisampleSource: multisampleSources) + { + if (full) + break; + + // The device's fixed sample rate - scale all loop/start/end positions to it. + recalculateSamplePositions (multisampleSource, SP404Mk2Constants.SAMPLE_RATE); + + final List zones = new ArrayList<> (); + for (final IGroup group: multisampleSource.getNonEmptyGroups (true)) + zones.addAll (group.getSampleZones ()); + if (zones.isEmpty ()) + continue; + + for (final ISampleZone zone: zones) + { + if (pad >= SP404Mk2Constants.PADS_PER_BANK) + { + bank++; + pad = 0; + } + if (bank >= SP404Mk2Constants.BANK_COUNT) + { + this.notifier.logError ("IDS_SP404MK2_TOO_MANY_PADS", zone.getName ()); + full = true; + break; + } + if (this.writePad (padconf, smplFolder, bank, pad, zone)) + pad++; + } + + // Each source starts a fresh bank. + bank++; + pad = 0; + } + + // Count the written pads from the name block for the log. + for (int i = 0; i < SP404Mk2Constants.PAD_COUNT; i++) + if (padconf[NAME_START + i * SP404Mk2Constants.NAME_SIZE] != ' ') + written++; + + final File padconfFile = new File (projectFolder, SP404Mk2Constants.PADCONF_FILE_NAME); + try (final OutputStream out = new BufferedOutputStream (Files.newOutputStream (padconfFile.toPath ()))) + { + out.write (padconf); + } + + this.notifier.log ("IDS_SP404MK2_WRITTEN_PADS", Integer.toString (written)); + this.notifier.log ("IDS_NOTIFY_PROGRESS_DONE"); + } + + + /** + * Write one pad: its RFWV sample file and its metadata + name record in the pad configuration. + * + * @param padconf The pad configuration to patch + * @param smplFolder The SMPL folder + * @param bank The bank index (0-9) + * @param pad The pad index within the bank (0-15) + * @param zone The zone to write + * @return True if the pad was written + * @throws IOException Could not write the sample file + */ + private boolean writePad (final byte [] padconf, final File smplFolder, final int bank, final int pad, final ISampleZone zone) throws IOException + { + final Optional sampleData = zone.getSampleData (); + if (sampleData.isEmpty ()) + return false; + + final WaveFile waveFile = AudioFileUtils.convertToWav (sampleData.get (), DESTINATION_FORMAT); + final int channels = waveFile.getFormatChunk ().getNumberOfChannels (); + if (channels < 1 || channels > 2) + { + this.notifier.logError ("IDS_NOTIFY_ERR_MONO", Integer.toString (channels), zone.getName ()); + return false; + } + + final byte [] littleEndian = waveFile.getDataChunk ().getData (); + final int bytesPerFrame = channels * 2; + final int pcmLength = littleEndian.length / bytesPerFrame * bytesPerFrame; + if (pcmLength <= 0) + return false; + final byte [] pcm = new byte [pcmLength]; + System.arraycopy (littleEndian, 0, pcm, 0, pcmLength); + SP404Mk2Constants.swap16 (pcm); + final int fileSize = SP404Mk2Constants.RFWV_HEADER_SIZE + pcmLength; + + final File smpFile = new File (smplFolder, String.format ("BANK%d-%02d.SMP", Integer.valueOf (bank + 1), Integer.valueOf (pad + 1))); + try (final OutputStream out = new BufferedOutputStream (Files.newOutputStream (smpFile.toPath ()))) + { + out.write (buildRfwvHeader (pcmLength, channels)); + out.write (pcm); + } + + final int padIndex = bank * SP404Mk2Constants.PADS_PER_BANK + pad; + writePadRecord (padconf, padIndex, zone, fileSize); + writePadName (padconf, padIndex, zone.getName ()); + return true; + } + + + /** + * Build the 32-byte RFWV header for a sample. + * + * @param pcmLength The number of PCM bytes that follow + * @param channels The number of channels + * @return The header + */ + private static byte [] buildRfwvHeader (final int pcmLength, final int channels) + { + final byte [] header = new byte [SP404Mk2Constants.RFWV_HEADER_SIZE]; + System.arraycopy (SP404Mk2Constants.RFWV_MAGIC, 0, header, 0, 4); + SP404Mk2Constants.putU32 (header, 4, (long) SP404Mk2Constants.RFWV_HEADER_SIZE + pcmLength - 8); + SP404Mk2Constants.putU32 (header, SP404Mk2Constants.RFWV_RATE, SP404Mk2Constants.SAMPLE_RATE); + SP404Mk2Constants.putU32 (header, SP404Mk2Constants.RFWV_CHANNELS, channels); + SP404Mk2Constants.putU32 (header, SP404Mk2Constants.RFWV_BITS, 16); + return header; + } + + + /** + * Write the header and lay down the fixed "empty pad" template into all 160 records, so unused + * pads match the device's own layout. + * + * @param padconf The pad configuration to fill + */ + private static void writeHeader (final byte [] padconf) + { + System.arraycopy (SP404Mk2Constants.RFPD_MAGIC, 0, padconf, 0, 4); + SP404Mk2Constants.putU32 (padconf, SP404Mk2Constants.HDR_PAD_COUNT, SP404Mk2Constants.PAD_COUNT); + SP404Mk2Constants.putU32 (padconf, SP404Mk2Constants.HDR_VERSION, 0x02000000); + SP404Mk2Constants.putU32 (padconf, SP404Mk2Constants.HDR_DATA_SIZE, (long) SP404Mk2Constants.PAD_COUNT * SP404Mk2Constants.PAD_RECORD_SIZE + (long) SP404Mk2Constants.PAD_COUNT * SP404Mk2Constants.NAME_SIZE); + // Observed constant header fields of unknown purpose. + SP404Mk2Constants.putU32 (padconf, 0x10, 0x000124B8); + SP404Mk2Constants.putU32 (padconf, 0x1C, 0x40); + SP404Mk2Constants.putU32 (padconf, 0x20, 0x40); + SP404Mk2Constants.putU32 (padconf, 0x24, 0x40); + for (int i = 0; i < 5; i++) + SP404Mk2Constants.putU32 (padconf, 0x2C + i * 4, 0x20); + for (int bank = 0; bank < SP404Mk2Constants.BANK_COUNT; bank++) + SP404Mk2Constants.putU32 (padconf, SP404Mk2Constants.HDR_BANK_BPM + bank * 4, DEFAULT_BANK_BPM); + + // Fill the name block with spaces (an all-space name marks an unused pad). + for (int i = NAME_START; i < NAME_START + SP404Mk2Constants.PAD_COUNT * SP404Mk2Constants.NAME_SIZE; i++) + padconf[i] = ' '; + + // The fixed scaffold of an unused pad record. + for (int i = 0; i < SP404Mk2Constants.PAD_COUNT; i++) + writeEmptyRecord (padconf, i); + } + + + /** + * Write the fixed constants of an empty pad record. + * + * @param padconf The pad configuration + * @param padIndex The global pad index + */ + private static void writeEmptyRecord (final byte [] padconf, final int padIndex) + { + final int o = META_START + padIndex * SP404Mk2Constants.PAD_RECORD_SIZE; + SP404Mk2Constants.putU32 (padconf, o + SP404Mk2Constants.PAD_VOLUME, 127); + SP404Mk2Constants.putU32 (padconf, o + SP404Mk2Constants.PAD_LOOP, 1); + SP404Mk2Constants.putU32 (padconf, o + SP404Mk2Constants.PAD_USED, 1); + SP404Mk2Constants.putU32 (padconf, o + SP404Mk2Constants.PAD_BPM, DEFAULT_PAD_BPM); + SP404Mk2Constants.putU32 (padconf, o + SP404Mk2Constants.PAD_SPEED, SPEED_100); + SP404Mk2Constants.putU32 (padconf, o + 0x44, 1); + SP404Mk2Constants.putU32 (padconf, o + SP404Mk2Constants.PAD_PAN, SP404Mk2Constants.PAN_CENTER); + SP404Mk2Constants.putU32 (padconf, o + 0x50, 1); + SP404Mk2Constants.putU32 (padconf, o + 0x54, 2); + SP404Mk2Constants.putU32 (padconf, o + 0x5C, 100); + } + + + /** + * Overlay the pad's parameters onto its (already scaffolded) record. The full sample is played + * (start at the beginning, end at the sample end). + * + * @param padconf The pad configuration + * @param padIndex The global pad index + * @param zone The zone + * @param fileSize The size of the pad's .SMP file (the sample-end value) + */ + private static void writePadRecord (final byte [] padconf, final int padIndex, final ISampleZone zone, final int fileSize) + { + final int o = META_START + padIndex * SP404Mk2Constants.PAD_RECORD_SIZE; + SP404Mk2Constants.putU32 (padconf, o + SP404Mk2Constants.PAD_END, fileSize); + SP404Mk2Constants.putU32 (padconf, o + SP404Mk2Constants.PAD_END2, fileSize); + SP404Mk2Constants.putU32 (padconf, o + SP404Mk2Constants.PAD_START, SP404Mk2Constants.START_SENTINEL); + SP404Mk2Constants.putU32 (padconf, o + SP404Mk2Constants.PAD_LOOP_START, SP404Mk2Constants.START_SENTINEL); + SP404Mk2Constants.putU32 (padconf, o + SP404Mk2Constants.PAD_VOLUME, levelFromGain (zone.getGain ())); + SP404Mk2Constants.putU32 (padconf, o + SP404Mk2Constants.PAD_LOOP, zone.getLoops ().isEmpty () ? 0 : 1); + + final double tuning = zone.getTuning (); + final int semitones = (int) tuning; + final int cents = (int) Math.round ((tuning - semitones) * 100.0); + SP404Mk2Constants.putU32 (padconf, o + SP404Mk2Constants.PAD_PITCH, semitones); + SP404Mk2Constants.putU32 (padconf, o + SP404Mk2Constants.PAD_FINE, cents); + + final int pan = Math.clamp (SP404Mk2Constants.PAN_CENTER + (int) Math.round (zone.getPanning () * 63.0), 0, 127); + SP404Mk2Constants.putU32 (padconf, o + SP404Mk2Constants.PAD_PAN, pan); + } + + + /** + * Write a pad name into the name block (up to 23 ASCII characters, NUL-terminated). + * + * @param padconf The pad configuration + * @param padIndex The global pad index + * @param name The name + */ + private static void writePadName (final byte [] padconf, final int padIndex, final String name) + { + final int offset = NAME_START + padIndex * SP404Mk2Constants.NAME_SIZE; + final byte [] ascii = name.getBytes (StandardCharsets.US_ASCII); + final int length = Math.min (ascii.length, SP404Mk2Constants.NAME_MAX_CHARS); + System.arraycopy (ascii, 0, padconf, offset, length); + padconf[offset + length] = 0; + } + + + /** + * Convert a gain in dB to the device's 0-127 level. + * + * @param gainDb The gain in dB + * @return The level, 0-127 + */ + private static int levelFromGain (final double gainDb) + { + return Math.clamp ((int) Math.round (Math.pow (10, gainDb / 20.0) * 127.0), 0, 127); + } +} diff --git a/src/main/java/de/mossgrabers/convertwithmoss/format/roland/sp404mk2/SP404Mk2Detector.java b/src/main/java/de/mossgrabers/convertwithmoss/format/roland/sp404mk2/SP404Mk2Detector.java new file mode 100644 index 00000000..a79fc556 --- /dev/null +++ b/src/main/java/de/mossgrabers/convertwithmoss/format/roland/sp404mk2/SP404Mk2Detector.java @@ -0,0 +1,270 @@ +// 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.roland.sp404mk2; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.Set; + +import de.mossgrabers.convertwithmoss.core.IMultisampleSource; +import de.mossgrabers.convertwithmoss.core.INotifier; +import de.mossgrabers.convertwithmoss.core.algorithm.MathUtils; +import de.mossgrabers.convertwithmoss.core.detector.AbstractDetector; +import de.mossgrabers.convertwithmoss.core.model.IGroup; +import de.mossgrabers.convertwithmoss.core.model.ISampleLoop; +import de.mossgrabers.convertwithmoss.core.model.ISampleZone; +import de.mossgrabers.convertwithmoss.core.model.enumeration.LoopType; +import de.mossgrabers.convertwithmoss.core.model.implementation.DefaultAudioMetadata; +import de.mossgrabers.convertwithmoss.core.model.implementation.DefaultGroup; +import de.mossgrabers.convertwithmoss.core.model.implementation.DefaultSampleLoop; +import de.mossgrabers.convertwithmoss.core.model.implementation.DefaultSampleZone; +import de.mossgrabers.convertwithmoss.core.model.implementation.InMemorySampleData; +import de.mossgrabers.convertwithmoss.core.settings.MetadataSettingsUI; + + +/** + * Detects Roland SP-404MK2 projects. A project is a folder holding a PADCONF.BIN pad + * configuration and a SMPL sub-folder of BANK<bank>-<pad>.SMP sample files. + * Since the SP-404MK2 is a pad sampler (each of its 160 pads plays one sample, there are no + * key-ranges), every populated bank is converted into one multi-sample whose pads become + * single-key zones - the same mapping used for the other pad devices. Projects written by + * {@link SP404Mk2Creator} round-trip through this detector. + * + * @author Jürgen Moßgraber + */ +public class SP404Mk2Detector extends AbstractDetector +{ + /** The MIDI key of the first pad of a bank; the following pads use ascending keys. */ + private static final int BASE_NOTE = 36; + + + /** + * Constructor. + * + * @param notifier The notifier + */ + public SP404Mk2Detector (final INotifier notifier) + { + super ("Roland SP-404MK2", "SP404MK2", notifier, new MetadataSettingsUI ("SP404MK2"), SP404Mk2Constants.PADCONF_ENDING); + } + + + /** {@inheritDoc} */ + @Override + public Set getFileEndings () + { + // The detector scans for the pad configuration (PADCONF.BIN, see the constructor) since it + // holds the pad mapping. For the format list the SMP sample ending is shown instead: it is + // the extension unique to the SP-404MK2 and the one users recognize. + return Set.of (SP404Mk2Constants.SAMPLE_ENDING); + } + + + /** {@inheritDoc} */ + @Override + public List readPresetFile (final File sourceFile) + { + if (this.waitForDelivery ()) + return Collections.emptyList (); + + try + { + final byte [] data = Files.readAllBytes (sourceFile.toPath ()); + final int headerSize = SP404Mk2Constants.headerSizeForLength (data.length); + if (headerSize < 0 || !SP404Mk2Constants.hasMagic (data, SP404Mk2Constants.RFPD_MAGIC)) + return Collections.emptyList (); + return this.parseProject (sourceFile, data, headerSize); + } + catch (final IOException ex) + { + this.notifier.logError ("IDS_NOTIFY_ERR_LOAD_FILE", ex); + return Collections.emptyList (); + } + } + + + /** + * Parse the pad configuration and build one multi-sample source per populated bank. + * + * @param sourceFile The PADCONF.BIN file + * @param data The content of the file + * @param headerSize The header size, which also determines the pad-metadata and pad-name block + * offsets (differs between the export and the internal PADCONF.BIN form) + * @return The multi-sample sources + * @throws IOException Could not read a sample file + */ + private List parseProject (final File sourceFile, final byte [] data, final int headerSize) throws IOException + { + final int metaStart = headerSize; + final int nameStart = SP404Mk2Constants.nameBlockStart (headerSize); + final File smplFolder = new File (sourceFile.getParentFile (), "SMPL"); + final String projectName = cleanProjectName (sourceFile.getParentFile ().getName ()); + final List bankGroups = new ArrayList<> (); + final List bankIndices = new ArrayList<> (); + int totalPads = 0; + + for (int bank = 0; bank < SP404Mk2Constants.BANK_COUNT; bank++) + { + final IGroup group = new DefaultGroup ("Pads"); + for (int pad = 0; pad < SP404Mk2Constants.PADS_PER_BANK; pad++) + { + final int padIndex = bank * SP404Mk2Constants.PADS_PER_BANK + pad; + final String padName = readPadName (data, nameStart, padIndex); + if (padName.isEmpty ()) + continue; + + final File smpFile = new File (smplFolder, String.format ("BANK%d-%02d.SMP", Integer.valueOf (bank + 1), Integer.valueOf (pad + 1))); + if (!smpFile.exists ()) + { + this.notifier.logError ("IDS_SP404MK2_SAMPLE_MISSING", smpFile.getName ()); + continue; + } + + final ISampleZone zone = this.createZone (data, metaStart, padIndex, pad, padName, smpFile); + if (zone != null) + { + group.addSampleZone (zone); + totalPads++; + } + } + + if (!group.getSampleZones ().isEmpty ()) + { + bankGroups.add (group); + bankIndices.add (Integer.valueOf (bank)); + } + } + + if (bankGroups.isEmpty ()) + { + this.notifier.logError ("IDS_SP404MK2_NO_PADS", projectName); + return Collections.emptyList (); + } + + // Only distinguish the banks by name if there is more than one. + final boolean singleBank = bankGroups.size () == 1; + final List results = new ArrayList<> (); + for (int i = 0; i < bankGroups.size (); i++) + { + final String name = singleBank ? projectName : projectName + " - Bank " + (char) ('A' + bankIndices.get (i).intValue ()); + results.add (this.createMultisampleSource (sourceFile, name, List.of (bankGroups.get (i)))); + } + this.notifier.log ("IDS_SP404MK2_READING_PROJECT", projectName, Integer.toString (totalPads)); + return results; + } + + + /** + * Create a single-key zone from a pad. The full sample is extracted; the on-device start/end + * trim is not applied (its byte-offset encoding is not fully decoded). + * + * @param data The pad configuration + * @param metaStart The offset of the pad-metadata block + * @param padIndex The global pad index (0-159) + * @param padInBank The pad index within its bank (0-15), which selects the MIDI key + * @param name The pad name + * @param smpFile The RFWV sample file + * @return The zone or null if the sample could not be read + * @throws IOException Could not read the sample file + */ + private ISampleZone createZone (final byte [] data, final int metaStart, final int padIndex, final int padInBank, final String name, final File smpFile) throws IOException + { + final byte [] smp = Files.readAllBytes (smpFile.toPath ()); + if (smp.length < SP404Mk2Constants.RFWV_HEADER_SIZE || !SP404Mk2Constants.hasMagic (smp, SP404Mk2Constants.RFWV_MAGIC)) + { + this.notifier.logError ("IDS_SP404MK2_SAMPLE_INVALID", smpFile.getName ()); + return null; + } + + final int rate = (int) SP404Mk2Constants.getU32 (smp, SP404Mk2Constants.RFWV_RATE); + final int channels = (int) SP404Mk2Constants.getU32 (smp, SP404Mk2Constants.RFWV_CHANNELS); + final int bits = (int) SP404Mk2Constants.getU32 (smp, SP404Mk2Constants.RFWV_BITS); + if (channels < 1 || channels > 2 || bits != 16) + { + this.notifier.logError ("IDS_SP404MK2_SAMPLE_INVALID", smpFile.getName ()); + return null; + } + + final int bytesPerFrame = channels * 2; + final int pcmLength = (smp.length - SP404Mk2Constants.RFWV_HEADER_SIZE) / bytesPerFrame * bytesPerFrame; + final int frames = pcmLength / bytesPerFrame; + if (frames <= 0) + return null; + final byte [] pcm = new byte [pcmLength]; + System.arraycopy (smp, SP404Mk2Constants.RFWV_HEADER_SIZE, pcm, 0, pcmLength); + SP404Mk2Constants.swap16 (pcm); + + final int key = BASE_NOTE + padInBank; + final ISampleZone zone = new DefaultSampleZone (name, key, key); + zone.setKeyRoot (key); + zone.setSampleData (new InMemorySampleData (new DefaultAudioMetadata (channels, rate, 16, frames), pcm)); + zone.setStart (0); + zone.setStop (frames); + + final int recordOffset = metaStart + padIndex * SP404Mk2Constants.PAD_RECORD_SIZE; + final int volume = (int) SP404Mk2Constants.getU32 (data, recordOffset + SP404Mk2Constants.PAD_VOLUME); + zone.setGain (MathUtils.valueToDb (Math.max (Math.min (volume, 127), 1) / 127.0)); + final int pan = (int) SP404Mk2Constants.getU32 (data, recordOffset + SP404Mk2Constants.PAD_PAN); + zone.setPanning (Math.clamp ((pan - SP404Mk2Constants.PAN_CENTER) / 63.0, -1.0, 1.0)); + final int pitch = SP404Mk2Constants.getS32 (data, recordOffset + SP404Mk2Constants.PAD_PITCH); + final int fine = SP404Mk2Constants.getS32 (data, recordOffset + SP404Mk2Constants.PAD_FINE); + zone.setTuning (pitch + fine / 100.0); + + if (SP404Mk2Constants.getU32 (data, recordOffset + SP404Mk2Constants.PAD_LOOP) != 0) + { + final ISampleLoop loop = new DefaultSampleLoop (); + loop.setType (LoopType.FORWARDS); + loop.setStart (0); + loop.setEnd (frames); + zone.addLoop (loop); + } + + return zone; + } + + + /** + * Read a pad name from the name block. Empty (all-space) names indicate an unused pad. + * + * @param data The pad configuration + * @param nameStart The offset of the pad-name block + * @param padIndex The global pad index (0-159) + * @return The trimmed name, or an empty string for an unused pad + */ + private static String readPadName (final byte [] data, final int nameStart, final int padIndex) + { + final int offset = nameStart + padIndex * SP404Mk2Constants.NAME_SIZE; + int length = 0; + while (length < SP404Mk2Constants.NAME_MAX_CHARS && data[offset + length] != 0) + length++; + return new String (data, offset, length, StandardCharsets.US_ASCII).trim (); + } + + + /** + * Turn a project folder name into a display name, stripping a leading "PROJECT_XX" or + * "PROJECT_XX-" slot prefix used by the device's project export. + * + * @param folderName The name of the project folder + * @return The cleaned name + */ + private static String cleanProjectName (final String folderName) + { + String name = folderName; + if (name.toUpperCase (Locale.US).startsWith ("PROJECT_") && name.length () > 10) + { + name = name.substring (10); + if (!name.isEmpty () && (name.charAt (0) == '-' || name.charAt (0) == '_' || name.charAt (0) == ' ')) + name = name.substring (1); + } + return name.isBlank () ? folderName : name.trim (); + } +} diff --git a/src/main/resources/Strings.properties b/src/main/resources/Strings.properties index 67dd9be7..b91b7219 100644 --- a/src/main/resources/Strings.properties +++ b/src/main/resources/Strings.properties @@ -414,6 +414,14 @@ IDS_MC707_ZONE_OUT_OF_RANGE=Some zones of '%1' are outside of the drum kit key r IDS_MC707_SEPARATOR=Multi-zone sources IDS_MC707_MULTISAMPLE_TONES=Write as multisample tones instead of drum kits (not verified on hardware) +IDS_SP404MK2_READING_PROJECT=Reading SP-404MK2 project '%1' (%2 pads).\n +IDS_SP404MK2_NO_PADS=No used pads found in SP-404MK2 project '%1'.\n +IDS_SP404MK2_WRITING_PROJECT=Writing SP-404MK2 project '%1'.\n +IDS_SP404MK2_WRITTEN_PADS=Wrote %1 pads.\n +IDS_SP404MK2_TOO_MANY_PADS=All 160 pads of the project are in use. '%1' skipped.\n +IDS_SP404MK2_SAMPLE_MISSING=Sample file '%1' referenced by the pad configuration not found. Skipped.\n +IDS_SP404MK2_SAMPLE_INVALID=Sample file '%1' is not a valid RFWV sample. Skipped.\n + IDS_ZENCORE_READING_SVZ=Reading ZEN-Core user samples '%1' (%2 zones).\n IDS_ZENCORE_NO_USER_SAMPLES=No convertible user samples found in '%1'.\n IDS_ZENCORE_WRITING_BANK=Writing ZEN-Core bank '%1' (%2 instruments) as a multi-tone .svz.\n