From bf866e53507947db7f310fd4fbe91b3c8c8ca058 Mon Sep 17 00:00:00 2001 From: Douglas Carmichael Date: Fri, 24 Jul 2026 18:29:33 -0400 Subject: [PATCH 1/2] Roland SP-404MK2: add support for reading and writing projects The SP-404MK2 is a pad sampler: a project holds 10 banks of 16 pads, each pad playing one sample. Like the other pad devices, each populated bank is converted to one multi-sample with a single-key zone per pad. Reads a project folder (PADCONF.BIN pad configuration - both the 31,488-byte export and the 52,000-byte internal form - plus the SMPL folder of RFWV BANK-

.SMP samples, 48 kHz/16-bit big-endian) and writes an importable project. The format was reverse-engineered and validated in software against real projects and the v5.52 firmware (see documentation/design/SP404MK2_FORMAT.md); writing is not yet verified on hardware. --- documentation/CHANGELOG.md | 4 + documentation/README-FORMATS.md | 13 + documentation/design/SP404MK2_FORMAT.md | 158 ++++++++ .../core/ConverterBackend.java | 4 + .../roland/sp404mk2/SP404Mk2Constants.java | 213 +++++++++++ .../roland/sp404mk2/SP404Mk2Creator.java | 350 ++++++++++++++++++ .../roland/sp404mk2/SP404Mk2Detector.java | 258 +++++++++++++ src/main/resources/Strings.properties | 8 + 8 files changed, 1008 insertions(+) create mode 100644 documentation/design/SP404MK2_FORMAT.md create mode 100644 src/main/java/de/mossgrabers/convertwithmoss/format/roland/sp404mk2/SP404Mk2Constants.java create mode 100644 src/main/java/de/mossgrabers/convertwithmoss/format/roland/sp404mk2/SP404Mk2Creator.java create mode 100644 src/main/java/de/mossgrabers/convertwithmoss/format/roland/sp404mk2/SP404Mk2Detector.java 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..54ddb312 --- /dev/null +++ b/src/main/java/de/mossgrabers/convertwithmoss/format/roland/sp404mk2/SP404Mk2Constants.java @@ -0,0 +1,213 @@ +// 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 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..ffaa48bd --- /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, "PADCONF.BIN"); + 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..85a14826 --- /dev/null +++ b/src/main/java/de/mossgrabers/convertwithmoss/format/roland/sp404mk2/SP404Mk2Detector.java @@ -0,0 +1,258 @@ +// 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 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"), "padconf.bin"); + } + + + /** {@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 From d5c6c629d83582ebecb652b6b0b436e243e256cf Mon Sep 17 00:00:00 2001 From: Douglas Carmichael Date: Sat, 25 Jul 2026 18:46:11 -0400 Subject: [PATCH 2/2] Roland SP-404MK2: show the SMP sample ending in the format list The format list showed the whole 'padconf.bin' file name as the ending of the format, which is long and is not an extension at all. Show '*.smp' instead - the ending unique to the SP-404MK2 - the same way the Synclavier Regen detector shows its '*.sflc' samples while scanning for the '-Entry.txt' timbre files. Detection is unchanged: the detector still scans for the PADCONF.BIN pad configuration, since that is the file which holds the pad mapping. --- .../format/roland/sp404mk2/SP404Mk2Constants.java | 6 ++++++ .../format/roland/sp404mk2/SP404Mk2Creator.java | 2 +- .../format/roland/sp404mk2/SP404Mk2Detector.java | 14 +++++++++++++- 3 files changed, 20 insertions(+), 2 deletions(-) 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 index 54ddb312..2aa52115 100644 --- a/src/main/java/de/mossgrabers/convertwithmoss/format/roland/sp404mk2/SP404Mk2Constants.java +++ b/src/main/java/de/mossgrabers/convertwithmoss/format/roland/sp404mk2/SP404Mk2Constants.java @@ -26,6 +26,12 @@ public final class SP404Mk2Constants 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). */ 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 index ffaa48bd..3eafcfcd 100644 --- a/src/main/java/de/mossgrabers/convertwithmoss/format/roland/sp404mk2/SP404Mk2Creator.java +++ b/src/main/java/de/mossgrabers/convertwithmoss/format/roland/sp404mk2/SP404Mk2Creator.java @@ -157,7 +157,7 @@ private void writeProject (final File destinationFolder, final List */ public SP404Mk2Detector (final INotifier notifier) { - super ("Roland SP-404MK2", "SP404MK2", notifier, new MetadataSettingsUI ("SP404MK2"), "padconf.bin"); + 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); }