From 20b7fba617ea1154e896127b602ed33322405ef2 Mon Sep 17 00:00:00 2001 From: Douglas Carmichael Date: Fri, 24 Jul 2026 11:37:38 -0400 Subject: [PATCH 01/12] Added support for the E-mu Emulator IV bank format (E4B) Reads and writes Emulator 4/E4X/E4XT/E4K (EOS) .e4b banks. Every preset of a bank becomes one multi-sample (voices become groups); writing turns each multi-sample into a preset of a bank, with library support for up to 1000 presets/1000 samples per bank and content-based sample de-duplication. Mapped: names, key/velocity ranges, root keys, tuning, volume, forward loops, amplitude envelope (incl. hold and velocity modulation), filter type/cutoff/resonance/key tracking and the filter envelope with its depth. Stereo samples are mixed down to mono on write. The layout follows the reverse-engineering of the mpc2emu project (see documentation/design/E4B_FORMAT.md). Validated against the mpc2emu reference parser and hardware-created third-party banks (198 presets, 1980 zones, all sample PCM byte-identical on read; written banks re-parse with identical mapping). Not yet tested on real hardware. --- documentation/CHANGELOG.md | 4 + documentation/README-FORMATS.md | 8 + documentation/design/E4B_FORMAT.md | 208 ++++++ .../core/ConverterBackend.java | 4 + .../emu/emulator4/Emulator4Constants.java | 449 ++++++++++++ .../emu/emulator4/Emulator4Creator.java | 693 ++++++++++++++++++ .../emu/emulator4/Emulator4Detector.java | 475 ++++++++++++ src/main/resources/Strings.properties | 11 + 8 files changed, 1852 insertions(+) create mode 100644 documentation/design/E4B_FORMAT.md create mode 100644 src/main/java/de/mossgrabers/convertwithmoss/format/emu/emulator4/Emulator4Constants.java create mode 100644 src/main/java/de/mossgrabers/convertwithmoss/format/emu/emulator4/Emulator4Creator.java create mode 100644 src/main/java/de/mossgrabers/convertwithmoss/format/emu/emulator4/Emulator4Detector.java diff --git a/documentation/CHANGELOG.md b/documentation/CHANGELOG.md index 8d17eafa..70ae586f 100644 --- a/documentation/CHANGELOG.md +++ b/documentation/CHANGELOG.md @@ -1,5 +1,9 @@ # Changes +## 19.2.0 + +* New: Added support for the E-mu Emulator IV bank format (E4B). Written banks have not been tested on real hardware yet. + ## 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..817e7c34 100644 --- a/documentation/README-FORMATS.md +++ b/documentation/README-FORMATS.md @@ -275,6 +275,14 @@ Both the program (.zbp) as well as the bank (.zbb) are stored as monoliths (zipp The DLS format (*.dls) is a standardized file format developed for storing and distributing collections of digital musical instrument sounds, enabling their use in software synthesizers and hardware devices compatible with the MIDI protocol. It encapsulates audio samples, instrument definitions, articulations, and performance parameters into a single file. Developed in the 1990s initially by the Interactive Audio Special Interest Group (IASIG) and later standardized by the MIDI Manufacturers Association (MMA), with the first formal specification released in 1999. There is no write support. +## E-mu Emulator IV + +The E-mu Emulator IV series (Emulator 4, E4X, E4XT, E4K, e-Synth, e-6400 and the other EOS samplers, 1994-2002) stores its banks in single *.e4b* files which contain all presets, their parameters and the sample data. A preset layers several voices; each voice maps a set of zones (a key/velocity range referencing a sample) and carries the tuning, volume, filter, envelope and modulation settings for them. The format is not documented by E-mu, the layout was reverse-engineered by the mpc2emu project from hardware-saved E4XT banks and commercial EOS CD-ROMs (see *documentation/design/E4B_FORMAT.md*). + +When reading, every preset of a bank becomes one multi-sample and every voice becomes a group. Names, key and velocity ranges, root keys, tuning, volume, loops, the amplitude envelope with its velocity modulation, the filter (type, cutoff, resonance, key tracking and envelope with its depth) and the 16-bit sample data are read. The EOS effect filter types (phasers, flangers, vocal formants, EQ morphs) have no model equivalent, such voices are converted without a filter. + +When writing, each multi-sample becomes one preset; a library collects all multi-samples into a single bank (up to 1000 presets and 1000 samples). Every zone is written as its own voice, which keeps all per-zone settings. Samples are stored as 16-bit mono PCM with their original sample rate; stereo samples are mixed down to mono. Identical samples mapped to multiple zones are stored only once. Since EOS only has a sample-level forward loop, alternating loops are written as forward loops and only the first loop of a zone is kept. Written banks validate against the reference parser of the mpc2emu project but have not been verified on real hardware yet. + ## Elektron Tonverk The Elektron Tonverk is a dedicated hardware sampler that marks an important milestone for Elektron as its first instrument to support multi-samples. This allows users to map multiple sampled sounds across keys or velocity ranges, creating more expressive and realistic instruments than single-sample playback alone. diff --git a/documentation/design/E4B_FORMAT.md b/documentation/design/E4B_FORMAT.md new file mode 100644 index 00000000..c385e864 --- /dev/null +++ b/documentation/design/E4B_FORMAT.md @@ -0,0 +1,208 @@ +# E-mu Emulator IV Bank Format (E4B) + +Banks of the E-mu Emulator IV series (Emulator 4, E4X, E4XT, E4K, e-Synth, +e-6400 and the other EOS samplers, 1994-2002) are stored in single `*.e4b` +files which contain all presets, their parameters and the sample data. + +The format is not documented by E-mu. The byte-level layout used here was +reverse-engineered by the **mpc2emu** project (GPL-2.0-or-later, +`docs/E4B_FORMAT.md` and `writers/e4b_writer.py`/`parsers/e4b_parser.py` in its +source tree) by differential analysis of hardware-saved E4XT banks (created by +Jan Lentfer), commercial EOS CD-ROMs, the `struct emu3_sample` layout of +[emu3bm](https://github.com/dagargo/emu3bm) by David García Goñi and Phil's E4 +format notes (philizound.co.uk). The implementation in +`format/emu/emulator4` is an independent Java implementation based on that +documentation; no code was copied. + +## Container + +An E4B file is an IFF-like container. All chunk sizes and all indices are +big-endian; the fields inside the E3S1 sample struct are little-endian. + +``` +FORM E4B0 + TOC1 table of contents, one 32-byte entry per chunk below (not EMSt) + E4Ma 256-byte MIDI multimap (channel -> preset routing) + E4P1 one chunk per preset + E3S1 one chunk per sample (16-bit mono little-endian PCM) + EMSt 1366-byte master setup, always the last chunk, NOT listed in the TOC +``` + +Chunks are word-aligned (a pad byte follows an odd-sized chunk). Two quirks of +the `FORM` size field: + +* Hardware-saved EOS 4.x banks use the EMU convention `size = filesize - 12`, + i.e. 4 **less** than the standard IFF value - the 4-byte `E4B0` form type is + not counted. The declared FORM boundary therefore ends 4 bytes short of the + file end, inside the trailing zeros of the `EMSt` chunk (which is why `EMSt` + must be last: when streaming from CD the E4XT clips at the FORM boundary). +* Banks written by older EOS versions exist in the wild **without** an `EMSt` + chunk and with a FORM size *larger* than the file. The detector therefore + ignores the FORM size completely and walks the chunks against the real file + length; the creator writes the EMU convention including the trailing `EMSt`. + +The `E4Ma` and `EMSt` blocks are not fully decoded; the creator writes the +defaults captured from hardware-saved banks ("all presets on all channels" and +the "Untitled MSetup" block). + +### TOC1 entry (32 bytes) + +| Offset | Size | Content | +|---|---|---| +| 0 | 4 | chunk tag (`E4Ma`, `E4P1`, `E3S1`) | +| 4 | 4 | chunk data size | +| 8 | 4 | absolute file offset of the chunk tag | +| 12 | 2 | index: 0 for `E4Ma`, 0-based preset index, **1-based** sample index | +| 14 | 16 | name, space-padded ASCII | +| 30 | 1 | 0 | +| 31 | 1 | MIDI program number (0 = any) | + +## Preset (E4P1) + +An 82-byte header followed by the voice blocks, packed back-to-back: + +| Offset | Size | Content | +|---|---|---| +| 0 | 2 | preset index (0-based) | +| 2 | 16 | name, space-padded ASCII | +| 19 | 1 | 0x52 constant | +| 20 | 2 | number of voices | +| 28 | 1 | preset volume (0x78 = default) | +| 41 | 1 | 0x04 if more than one voice | +| 43 | 1 | 0x01 if more than one voice | +| 52 | 4 | 0x52 0x23 0x00 0x7E constant | +| 56 | 4 | 0xFF 0xFF 0xFF 0xFF (MIDI any note/any channel) | +| 82 | | voice blocks | + +## Voice block + +284 fixed bytes followed by `n * 22` bytes of zone entries. There is **no +separator** between voices; the next voice starts right after the zone table. +Only the last voice of a preset is followed by two zero bytes. The 16-bit +value at voice offset 2 is `284 + n * 22` (relative to the voice start), which +is how the number of zones - and the start of the next voice - is found. + +``` +voice[ 0:110] voice parameters +voice[110:174] primary zone table (envelopes, LFOs) +voice[174:190] zero +voice[190:270] modulation cord table (20 cords of 4 bytes) +voice[270:284] zero +voice[284: ] zone entries (22 bytes each) +``` + +Used voice parameters (all single bytes unless noted): + +| Offset | Content | +|---|---| +| 2:4 | zone table end offset (u16, see above) | +| 4 | number of zones | +| 7 | 0x64 constant | +| 14/17 | key window low/high | +| 18/21 | velocity window low/high - **must mirror** the min/max of the zone entries, otherwise velocity layers stack instead of switching | +| 25 | 0x7F constant | +| 34 | key transpose (signed semitones) | +| 35 | coarse tune (signed semitones) | +| 36 | fine tune (signed, 1/64 semitone units) | +| 38 | 1 = non-transpose (fixed pitch); such a voice needs a populated cord table to be valid | +| 42 | chorus amount (0-127) | +| 51 | 0x80 constant | +| 54 | voice volume (signed dB) | +| 58 | filter type (see below) | +| 60 | filter cutoff: 0 = ~57 Hz ... 255 = 20 kHz, exponential | +| 61 | filter resonance 0-127 | + +Filter types (`byte = group | variant`, variant in the low 3 bits): low-pass +4/2/6-pole = 0x00/0x01/0x02, high-pass 2nd/4th = 0x08/0x09, band-pass 2nd/4th = +0x10/0x11, contrary band-pass (notch-like) = 0x12. The remaining groups (swept +EQ 0x20+, phaser 0x40+, flanger 0x48, vocal 0x50+, morph 0x60+) are EOS effect +filters without a model equivalent. + +### Primary zone table + +Two 6-stage envelopes as rate/level byte pairs, plus the LFO parameters. The +amplitude envelope stages are at bytes 0-11 (attack1, attack2, decay1, decay2, +release1, release2), the filter envelope mirrors this at bytes 14-25. In the +standard ADSR shape attack 1 rises to full level, decay 1 falls to the sustain +level (decay 2 holds it) and release 1 falls to silence. An attack 2 stage +with the same level as attack 1 is a plateau and expresses a hold stage. + +The rate <-> time law was calibrated on E4XT hardware by mpc2emu: +`seconds = 0.0310 * e^(0.0581 * rate)`, rate 0 = instant, 127 = ~47 s. Levels +are stored as `round(percent * 127 / 100)` (signed for the filter envelope). + +The filter envelope shape alone is inert: its depth is the amount of the +"cord 5" modulation route (below). + +### Modulation cord table + +20 cords of `[source, destination, amount, 0]`; amounts are signed +(`round(percent / 100 * 127)`). The EOS factory default cord set occupies the +first 8 slots. Routes used by the converter: + +| Slot | Route | Use | +|---|---|---| +| 0 | velocity (0x0A add / 0x0B centered / 0x0C subtract) -> amp (0x40) | velocity modulation of the amplitude, default amount 0x1E | +| 5 | filter envelope (0x50) -> filter frequency (0x38) | the depth of the filter envelope | +| 6 | key (0x08) -> filter frequency (0x38) | filter key tracking; +100% tracks ~0.713 octave per octave | + +An all-zero table is valid for a plain key-tracking voice, but a +**non-transpose voice requires the populated default table** to be recognized +by the hardware, so the creator always writes the factory default set. + +### Zone entry (22 bytes) + +| Offset | Content | +|---|---| +| 2 | low key | +| 5 | high key | +| 6 | low velocity | +| 9 | high velocity | +| 10:12 | sample index (u16, 1-based) | +| 14 | root key | + +## Sample (E3S1) + +A 94-byte header (2-byte sample index + the 92-byte EOS sample struct known +from emu3bm) followed by 16-bit little-endian mono PCM. All position fields +are byte offsets relative to the struct start (i.e. 92 = first PCM byte): + +| Offset | Size | Content | +|---|---|---| +| 0 | 2 | sample index (1-based, big-endian) | +| 2 | 16 | name, space-padded ASCII; the root note is conventionally appended (e.g. `_C3` for MIDI 60, octave = note/12 - 2) | +| 22 | 4 | start = 92 | +| 30 | 4 | end = 92 + PCM bytes - 2 | +| 38 | 4 | loop start | +| 46 | 4 | loop end | +| 54 | 4 | sample rate | +| 60 | 2 | options: 0x0020 = mono, 0x0031 = mono with forward loop | +| 62 | 4 | data offset = 92 | + +EOS has exactly one loop type (forward, on/off at the sample level). A forward +loop shorter than ~84 frames plays an octave low on the hardware (the E4XT +doubles it silently). + +## Mapping decisions of the converter + +* **Reading:** every preset becomes one multi-sample source, every voice one + group. The voice tuning (transpose + coarse + fine) and volume are applied + to its zones; a non-transpose voice sets key tracking 0. A fully open + 4-pole low-pass without resonance, envelope depth and key tracking is the + EOS bypass state and creates no filter. +* **Writing:** every zone becomes its own voice with a single zone entry, + which keeps the per-zone tuning, volume, filter and envelopes (hardware + banks typically map many zones into one voice; both layouts are valid). + Stereo samples are mixed down to mono (the stereo variant of the sample + struct is not covered by the mpc2emu reverse-engineering). Samples are + de-duplicated by content. Trims (zone start/stop) are not applied; use the + trim processing option instead. +* Bank limits: 1000 presets and 1000 samples (S000-S999/P000-P999 per the EOS + manual, which is also why the zone sample index must be 16-bit). + +## Status + +Read and write are validated against the mpc2emu reference parser and against +hardware-created third-party banks (Ian Wilson's free EOS banks: 198 presets, +1980 zones, 317 samples - all sample PCM byte-identical, all zones resolved). +Written banks have **not** been loaded on real hardware yet. diff --git a/src/main/java/de/mossgrabers/convertwithmoss/core/ConverterBackend.java b/src/main/java/de/mossgrabers/convertwithmoss/core/ConverterBackend.java index 71a8c8d5..7cbce336 100644 --- a/src/main/java/de/mossgrabers/convertwithmoss/core/ConverterBackend.java +++ b/src/main/java/de/mossgrabers/convertwithmoss/core/ConverterBackend.java @@ -51,6 +51,8 @@ import de.mossgrabers.convertwithmoss.format.elektron.TonverkMultiDetector; import de.mossgrabers.convertwithmoss.format.elektron.TonverkPresetCreator; import de.mossgrabers.convertwithmoss.format.elektron.TonverkPresetDetector; +import de.mossgrabers.convertwithmoss.format.emu.emulator4.Emulator4Creator; +import de.mossgrabers.convertwithmoss.format.emu.emulator4.Emulator4Detector; import de.mossgrabers.convertwithmoss.format.ensoniq.epsasr.EnsoniqEpsAsrDetector; import de.mossgrabers.convertwithmoss.format.ensoniq.mirage.MirageDetector; import de.mossgrabers.convertwithmoss.format.exs.EXS24Creator; @@ -159,6 +161,7 @@ public ConverterBackend (final INotifier notifier) this.detectors.add (new DecentSamplerDetector (notifier)); this.detectors.add (new DlsDetector (notifier)); this.detectors.add (new DistingExDetector (notifier)); + this.detectors.add (new Emulator4Detector (notifier)); this.detectors.add (new TonverkMultiDetector (notifier)); this.detectors.add (new TonverkPresetDetector (notifier)); this.detectors.add (new EnsoniqEpsAsrDetector (notifier)); @@ -199,6 +202,7 @@ public ConverterBackend (final INotifier notifier) this.creators.add (new TX16WxCreator (notifier)); this.creators.add (new DecentSamplerCreator (notifier)); this.creators.add (new DistingExCreator (notifier)); + this.creators.add (new Emulator4Creator (notifier)); this.creators.add (new TonverkMultiCreator (notifier)); this.creators.add (new TonverkPresetCreator (notifier)); this.creators.add (new KMPCreator (notifier)); diff --git a/src/main/java/de/mossgrabers/convertwithmoss/format/emu/emulator4/Emulator4Constants.java b/src/main/java/de/mossgrabers/convertwithmoss/format/emu/emulator4/Emulator4Constants.java new file mode 100644 index 00000000..b2f3ddb3 --- /dev/null +++ b/src/main/java/de/mossgrabers/convertwithmoss/format/emu/emulator4/Emulator4Constants.java @@ -0,0 +1,449 @@ +// 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.emu.emulator4; + +import java.util.Base64; + + +/** + * Constants and conversion helpers for the E-mu Emulator IV bank format (E4B). An E4B file is an + * IFF-like 'FORM E4B0' container holding a table of contents (TOC1), a MIDI multimap (E4Ma), one + * E4P1 chunk per preset, one E3S1 chunk per sample and a trailing EMSt master setup chunk. All + * chunk sizes and indices are big-endian, the fields inside the E3S1 sample header are + * little-endian. The layout was reverse-engineered by the mpc2emu project from hardware-saved E4XT + * banks and commercial EOS CD-ROMs; see documentation/design/E4B_FORMAT.md for the details. + * + * @author Jürgen Moßgraber + */ +public class Emulator4Constants +{ + /** The magic tag of the outer chunk. */ + public static final byte [] FORM_MAGIC = "FORM".getBytes (); + /** The form type of an E4B bank. */ + public static final byte [] FORM_TYPE = "E4B0".getBytes (); + /** The tag of the table of contents chunk. */ + public static final byte [] TOC_TAG = "TOC1".getBytes (); + /** The tag of the MIDI multimap chunk. */ + public static final byte [] E4MA_TAG = "E4Ma".getBytes (); + /** The tag of a preset chunk. */ + public static final byte [] PRESET_TAG = "E4P1".getBytes (); + /** The tag of a sample chunk. */ + public static final byte [] SAMPLE_TAG = "E3S1".getBytes (); + /** The tag of the master setup chunk (always the last chunk, not listed in the TOC). */ + public static final byte [] EMST_TAG = "EMSt".getBytes (); + + /** The length of all name fields (space padded ASCII without a terminator). */ + public static final int NAME_LENGTH = 16; + /** The size of a TOC entry. */ + public static final int TOC_ENTRY_SIZE = 32; + /** The size of the E4Ma multimap chunk. */ + public static final int E4MA_SIZE = 256; + /** The size of an E3S1 sample header: 2 bytes sample index + the 92 byte EOS sample struct. */ + public static final int SAMPLE_HEADER_SIZE = 94; + /** The size of the EOS sample struct; all start/end/loop offsets are relative to its start. */ + public static final int SAMPLE_STRUCT_SIZE = 92; + /** The size of the fixed E4P1 preset header. */ + public static final int PRESET_HEADER_SIZE = 82; + /** The size of the fixed part of a voice block. */ + public static final int VOICE_SIZE = 284; + /** The offset of the primary zone table inside a voice block. */ + public static final int VOICE_PZT_OFFSET = 110; + /** The offset of the modulation cord table inside a voice block. */ + public static final int VOICE_MOD_OFFSET = 190; + /** The size of the modulation cord table (20 cords of 4 bytes). */ + public static final int VOICE_MOD_SIZE = 80; + /** The size of a zone entry in the secondary zone table of a voice. */ + public static final int ZONE_ENTRY_SIZE = 22; + + /** The maximum number of samples in a bank (S000-S999). */ + public static final int MAX_SAMPLES = 1000; + /** The maximum number of presets in a bank (P000-P999). */ + public static final int MAX_PRESETS = 1000; + + /** The 'forward loop on' bit of the sample options field. */ + public static final int OPTION_LOOP = 0x0001; + /** The sample options of a mono sample without a loop. */ + public static final int OPTIONS_MONO = 0x0020; + /** The sample options of a mono sample with a forward loop. */ + public static final int OPTIONS_MONO_LOOP = 0x0031; + + /** The lowest filter cutoff frequency (cutoff byte 0). */ + public static final double CUTOFF_MIN_HERTZ = 57.0; + /** The highest filter cutoff frequency (cutoff byte 255). */ + public static final double CUTOFF_MAX_HERTZ = 20000.0; + + /** The default velocity-to-amplitude modulation amount of the EOS factory cord set (~24%). */ + public static final int DEFAULT_VELOCITY_AMOUNT = 0x1E; + /** The default release rate of the EOS factory envelope. */ + public static final int DEFAULT_RELEASE_RATE = 0x14; + /** The filter cutoff key tracking of a +100% Key-to-FilterFreq cord in octaves per octave. */ + public static final double FULL_KEY_TRACKING = 0.713; + + // The envelope rate to time law was calibrated on E4XT hardware by the mpc2emu project: + // seconds = 0.0310 * e^(0.0581 * rate) with rate 0 = instant and rate 127 = ~47 seconds + private static final double ENV_RATE_FACTOR = 0.0310; + private static final double ENV_RATE_EXPONENT = 0.0581; + + private static final String [] NOTE_NAMES = + { + "C", + "C#", + "D", + "D#", + "E", + "F", + "F#", + "G", + "G#", + "A", + "A#", + "B" + }; + + /** + * The primary zone table template of a voice (64 bytes). It carries the default amplitude + * envelope at [0..11], the default filter envelope at [14..25] and the default LFO settings, + * taken from a hardware saved reference voice. + */ + public static final byte [] PRIMARY_ZONE_TEMPLATE = + { + 0x00, 0x00, 0x00, 0x7F, 0x00, 0x7E, 0x00, 0x7F, + 0x14, 0x00, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, + 0x00, 0x7F, 0x00, 0x7E, 0x00, 0x7F, 0x14, 0x00, + 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x00, 0x7F, + 0x00, 0x7E, 0x00, 0x7F, 0x14, 0x00, 0x00, 0x00, + 0x03, 0x00, 0x40, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x01, 0x00, 0x40, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 + }; + + /** + * The EOS factory default modulation cord table (20 cords of [source, destination, amount, + * flag]). Cord 0 routes velocity to the amplitude (amount 0x1E = ~24%), cord 5 routes the + * filter envelope to the cutoff (amount 0 = inactive) and cord 6 routes the key position to + * the cutoff (amount 0 = no key tracking). A voice with an all zero table is valid but does + * not respond to velocity and must not use the non-transpose mode. + */ + public static final byte [] MOD_CORD_TEMPLATE = + { + 0x0C, 0x40, 0x1E, 0x00, 0x10, 0x30, 0x08, 0x00, + 0x60, 0x30, 0x00, 0x00, 0x11, (byte) 0xAA, 0x10, 0x00, + 0x0C, 0x38, 0x00, 0x00, 0x50, 0x38, 0x00, 0x00, + 0x08, 0x38, 0x00, 0x00, 0x16, 0x08, 0x7F, 0x00, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 + }; + + /** The amount byte offset of the velocity-to-amplitude cord in the cord table. */ + public static final int MOD_VELOCITY_AMOUNT = 2; + /** The amount byte offset of the filter-envelope-to-cutoff cord in the cord table. */ + public static final int MOD_FILTER_ENVELOPE_AMOUNT = 22; + /** The amount byte offset of the key-to-cutoff (filter key tracking) cord in the cord table. */ + public static final int MOD_KEY_TRACKING_AMOUNT = 26; + + /** The repeating 12 byte entry of the default E4Ma multimap (all presets on all channels). */ + public static final byte [] E4MA_ENTRY = + { + 0x00, 0x00, 0x00, 0x01, 0x7F, 0x00, (byte) 0xFF, 0x00, 0x00, 0x00, (byte) 0xFF, (byte) 0xFF + }; + + // The default 1366 byte 'Untitled MSetup' master setup block, captured from hardware-saved + // banks (identical across all freshly created banks). The FORM size deliberately stops 4 bytes + // short of the file end inside the trailing zeros of this chunk, so it must be the last chunk. + private static final String EMST_DEFAULT_BASE64 = "AABVbnRpdGxlZCBNU2V0dXAgAAACAAAAfwAAAAD/AAAAAAAAAAAAAAAAAAAAAAAAAAB/AAAA" + "AAB/AAAAAP8AAAAAAAAAAAAAAAAAAAAAAAAAAH8AAAAAAH8AAAAA/wAAAAAAAAAAAAAAAAAA" + "AAAAAAAAfwAAAAAAfwAAAAD/AAAAAAAAAAAAAAAAAAAAAAAAAAB/AAAAAAB/AAAAAP8AAAAA" + "AAAAAAAAAAAAAAAAAAAAAH8AAAAAAH8AAAAA/wAAAAAAAAAAAAAAAAAAAAAAAAAAfwAAAAAA" + "fwAAAAD/AAAAAAAAAAAAAAAAAAAAAAAAAAB/AAAAAAB/AAAAAP8AAAAAAAAAAAAAAAAAAAAA" + "AAAAAH8AAAAAAH8AAAAA/wAAAAAAAAAAAAAAAAAAAAAAAAAAfwAAAAAAfwAAAAD/AAAAAAAA" + "AAAAAAAAAAAAAAAAAAB/AAAAAAB/AAAAAP8AAAAAAAAAAAAAAAAAAAAAAAAAAH8AAAAAAH8A" + "AAAA/wAAAAAAAAAAAAAAAAAAAAAAAAAAfwAAAAAAfwAAAAD/AAAAAAAAAAAAAAAAAAAAAAAA" + "AAB/AAAAAAB/AAAAAP8AAAAAAAAAAAAAAAAAAAAAAAAAAH8AAAAAAH8AAAAA/wAAAAAAAAAA" + "AAAAAAAAAAAAAAAAfwAAAAAAfwAAAAD/AAAAAAAAAAAAAAAAAAAAAAAAAAB/AAAAAAB/AAAA" + "AP8AAAAAAAAAAAAAAAAAAAAAAAAAAH8AAAAAAH8AAAAA/wAAAAAAAAAAAAAAAAAAAAAAAAAA" + "fwAAAAAAfwAAAAD/AAAAAAAAAAAAAAAAAAAAAAAAAAB/AAAAAAB/AAAAAP8AAAAAAAAAAAAA" + "AAAAAAAAAAAAAH8AAAAAAH8AAAAA/wAAAAAAAAAAAAAAAAAAAAAAAAAAfwAAAAAAfwAAAAD/" + "AAAAAAAAAAAAAAAAAAAAAAAAAAB/AAAAAAB/AAAAAP8AAAAAAAAAAAAAAAAAAAAAAAAAAH8A" + "AAAAAH8AAAAA/wAAAAAAAAAAAAAAAAAAAAAAAAAAfwAAAAAAfwAAAAD/AAAAAAAAAAAAAAAA" + "AAAAAAAAAAB/AAAAAAB/AAAAAP8AAAAAAAAAAAAAAAAAAAAAAAAAAH8AAAAAAH8AAAAA/wAA" + "AAAAAAAAAAAAAAAAAAAAAAAAfwAAAAAAfwAAAAD/AAAAAAAAAAAAAAAAAAAAAAAAAAB/AAAA" + "AAB/AAAAAP8AAAAAAAAAAAAAAAAAAAAAAAAAAH8AAAAAAH8AAAAA/wAAAAAAAAAAAAAAAAAA" + "AAAAAAAAfwAAAAAAfwAAAAD/AAAAAAAAAAAAAAAAAAAAAAAAAAB/AAAAAAB/AAAAAP8AAAAA" + "AAAAAAAAAAAAAAAAAAAAAH8AAAD//////////wAAAAD/////AAAAAAAAAAAAAAAAAAAAAAAA" + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + "AAAAAAAAAAAAAAAAAAAAAA=="; + + + /** + * Private constructor since this is a utility class. + */ + private Emulator4Constants () + { + // Intentionally empty + } + + + /** + * Get the default master setup (EMSt) chunk content. + * + * @return The 1366 bytes of the default 'Untitled MSetup' block + */ + public static byte [] getDefaultMasterSetup () + { + return Base64.getDecoder ().decode (EMST_DEFAULT_BASE64); + } + + + /** + * Convert an envelope time in seconds to an EOS envelope rate byte. The law was calibrated on + * E4XT hardware: seconds = 0.0310 * e^(0.0581 * rate), 0 = instant, 127 = ~47 seconds. + * + * @param seconds The time in seconds + * @return The rate in the range of 0..127 + */ + public static int envelopeTimeToRate (final double seconds) + { + if (seconds <= 0) + return 0; + return Math.clamp ((int) Math.round ((Math.log (seconds) - Math.log (ENV_RATE_FACTOR)) / ENV_RATE_EXPONENT), 0, 127); + } + + + /** + * Convert an EOS envelope rate byte to a time in seconds. The inverse of + * {@link #envelopeTimeToRate}, except that rate 0 is treated as instant. + * + * @param rate The rate in the range of 0..127 + * @return The time in seconds + */ + public static double envelopeRateToTime (final int rate) + { + if (rate <= 0) + return 0; + return ENV_RATE_FACTOR * Math.exp (ENV_RATE_EXPONENT * Math.min (rate, 127)); + } + + + /** + * Convert a filter cutoff byte to a frequency in Hertz. The curve is exponential from ~57 Hz + * at 0 to 20 kHz at 255. + * + * @param cutoff The cutoff in the range of 0..255 + * @return The frequency in Hertz + */ + public static double cutoffToHertz (final int cutoff) + { + return CUTOFF_MIN_HERTZ * Math.pow (CUTOFF_MAX_HERTZ / CUTOFF_MIN_HERTZ, Math.clamp (cutoff, 0, 255) / 255.0); + } + + + /** + * Convert a filter cutoff frequency in Hertz to the cutoff byte. The inverse of + * {@link #cutoffToHertz}. + * + * @param frequency The frequency in Hertz + * @return The cutoff in the range of 0..255 + */ + public static int hertzToCutoff (final double frequency) + { + final double limited = Math.clamp (frequency, CUTOFF_MIN_HERTZ, CUTOFF_MAX_HERTZ); + return Math.clamp ((int) Math.round (255.0 * Math.log (limited / CUTOFF_MIN_HERTZ) / Math.log (CUTOFF_MAX_HERTZ / CUTOFF_MIN_HERTZ)), 0, 255); + } + + + /** + * Format the note name suffix which the format appends to sample names to make the root note + * visible in hardware browsers, e.g. '_C3' for MIDI note 60. + * + * @param midiNote The MIDI note + * @return The suffix including the leading underscore + */ + public static String formatNoteSuffix (final int midiNote) + { + return "_" + NOTE_NAMES[midiNote % 12] + Integer.toString (midiNote / 12 - 2); + } + + + /** + * Look up a note name (without the leading underscore) and octave as used in the sample name + * suffix and calculate the MIDI note. + * + * @param noteName The note name, e.g. 'C#' + * @param octave The octave, e.g. 3 for the middle C + * @return The MIDI note or -1 if the name is not a valid note + */ + public static int lookupNote (final String noteName, final int octave) + { + for (int i = 0; i < NOTE_NAMES.length; i++) + if (NOTE_NAMES[i].equals (noteName)) + { + final int midiNote = (octave + 2) * 12 + i; + return midiNote >= 0 && midiNote <= 127 ? midiNote : -1; + } + return -1; + } + + + /** + * Read an unsigned big-endian 16 bit value. + * + * @param data The data to read from + * @param offset The offset of the value + * @return The value + */ + public static int getU16BE (final byte [] data, final int offset) + { + return (data[offset] & 0xFF) << 8 | data[offset + 1] & 0xFF; + } + + + /** + * Write an unsigned big-endian 16 bit value. + * + * @param data The data to write to + * @param offset The offset of the value + * @param value The value to write + */ + public static void putU16BE (final byte [] data, final int offset, final int value) + { + data[offset] = (byte) (value >> 8 & 0xFF); + data[offset + 1] = (byte) (value & 0xFF); + } + + + /** + * Read an unsigned big-endian 32 bit value. + * + * @param data The data to read from + * @param offset The offset of the value + * @return The value + */ + public static long getU32BE (final byte [] data, final int offset) + { + return (long) (data[offset] & 0xFF) << 24 | (data[offset + 1] & 0xFF) << 16 | (data[offset + 2] & 0xFF) << 8 | data[offset + 3] & 0xFF; + } + + + /** + * Write an unsigned big-endian 32 bit value. + * + * @param data The data to write to + * @param offset The offset of the value + * @param value The value to write + */ + public static void putU32BE (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); + } + + + /** + * Read an unsigned little-endian 32 bit value. + * + * @param data The data to read from + * @param offset The offset of the value + * @return The value + */ + public static long getU32LE (final byte [] data, final int offset) + { + return (long) (data[offset + 3] & 0xFF) << 24 | (data[offset + 2] & 0xFF) << 16 | (data[offset + 1] & 0xFF) << 8 | data[offset] & 0xFF; + } + + + /** + * Write an unsigned little-endian 32 bit value. + * + * @param data The data to write to + * @param offset The offset of the value + * @param value The value to write + */ + public static void putU32LE (final byte [] data, final int offset, final long value) + { + data[offset] = (byte) (value & 0xFF); + data[offset + 1] = (byte) (value >> 8 & 0xFF); + data[offset + 2] = (byte) (value >> 16 & 0xFF); + data[offset + 3] = (byte) (value >> 24 & 0xFF); + } + + + /** + * Read an unsigned little-endian 16 bit value. + * + * @param data The data to read from + * @param offset The offset of the value + * @return The value + */ + public static int getU16LE (final byte [] data, final int offset) + { + return (data[offset + 1] & 0xFF) << 8 | data[offset] & 0xFF; + } + + + /** + * Write an unsigned little-endian 16 bit value. + * + * @param data The data to write to + * @param offset The offset of the value + * @param value The value to write + */ + public static void putU16LE (final byte [] data, final int offset, final int value) + { + data[offset] = (byte) (value & 0xFF); + data[offset + 1] = (byte) (value >> 8 & 0xFF); + } + + + /** + * Interpret a byte as a signed value. + * + * @param value The byte + * @return The value in the range of -128..127 + */ + public static int getSigned (final byte value) + { + return value; + } + + + /** + * Check if the data contains the given magic bytes at the given offset. + * + * @param data The data to check + * @param offset The offset of the magic bytes + * @param magic The magic bytes + * @return True if the magic bytes are present + */ + public static boolean hasMagic (final byte [] data, final int offset, final byte [] magic) + { + if (data.length < offset + magic.length) + return false; + for (int i = 0; i < magic.length; i++) + if (data[offset + i] != magic[i]) + return false; + return true; + } + + + /** + * Decode a 16 character space padded ASCII name field. + * + * @param data The data to read from + * @param offset The offset of the name field + * @return The trimmed name + */ + public static String decodeName (final byte [] data, final int offset) + { + final StringBuilder sb = new StringBuilder (NAME_LENGTH); + for (int i = 0; i < NAME_LENGTH; i++) + { + final int c = data[offset + i] & 0xFF; + sb.append (c >= 0x20 && c < 0x7F ? (char) c : '?'); + } + return sb.toString ().trim (); + } + + + /** + * Encode a name as a 16 byte space padded ASCII field. Characters outside of the printable + * ASCII range are replaced with a question mark. + * + * @param data The data to write to + * @param offset The offset of the name field + * @param name The name to encode + */ + public static void encodeName (final byte [] data, final int offset, final String name) + { + for (int i = 0; i < NAME_LENGTH; i++) + { + char c = i < name.length () ? name.charAt (i) : ' '; + if (c < 0x20 || c >= 0x7F) + c = '?'; + data[offset + i] = (byte) c; + } + } +} diff --git a/src/main/java/de/mossgrabers/convertwithmoss/format/emu/emulator4/Emulator4Creator.java b/src/main/java/de/mossgrabers/convertwithmoss/format/emu/emulator4/Emulator4Creator.java new file mode 100644 index 00000000..00f5f7d3 --- /dev/null +++ b/src/main/java/de/mossgrabers/convertwithmoss/format/emu/emulator4/Emulator4Creator.java @@ -0,0 +1,693 @@ +// 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.emu.emulator4; + +import java.io.BufferedOutputStream; +import java.io.File; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.ByteBuffer; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +import de.mossgrabers.convertwithmoss.core.IMultisampleSource; +import de.mossgrabers.convertwithmoss.core.INotifier; +import de.mossgrabers.convertwithmoss.core.creator.AbstractCreator; +import de.mossgrabers.convertwithmoss.core.creator.DestinationAudioFormat; +import de.mossgrabers.convertwithmoss.core.model.IEnvelope; +import de.mossgrabers.convertwithmoss.core.model.IEnvelopeModulator; +import de.mossgrabers.convertwithmoss.core.model.IFilter; +import de.mossgrabers.convertwithmoss.core.model.IGroup; +import de.mossgrabers.convertwithmoss.core.model.ISampleData; +import de.mossgrabers.convertwithmoss.core.model.ISampleLoop; +import de.mossgrabers.convertwithmoss.core.model.ISampleZone; +import de.mossgrabers.convertwithmoss.core.model.enumeration.FilterType; +import de.mossgrabers.convertwithmoss.core.model.enumeration.LoopType; +import de.mossgrabers.convertwithmoss.core.settings.EmptySettingsUI; +import de.mossgrabers.convertwithmoss.file.AudioFileUtils; +import de.mossgrabers.convertwithmoss.file.wav.WaveFile; + + +/** + * Creator for E-mu Emulator IV bank files (*.e4b). Every multi-sample source becomes one preset in + * the bank; a library collects all sources into a single bank. Every zone is written as one voice + * with a single zone entry, which keeps the per-zone tuning, volume, filter and envelope settings. + * Samples are stored as 16-bit mono PCM (stereo sources are mixed down) and are de-duplicated by + * their content. The format was reverse-engineered by the mpc2emu project, see + * documentation/design/E4B_FORMAT.md. Written banks have not been verified on hardware yet but + * validate against the mpc2emu reference parser. Files round-trip through + * {@link Emulator4Detector}. + * + * @author Jürgen Moßgraber + */ +public class Emulator4Creator extends AbstractCreator +{ + private static final DestinationAudioFormat DESTINATION_FORMAT = new DestinationAudioFormat (new int [] + { + 16 + }, -1, false); + + + /** Holds one de-duplicated sample to be written as an E3S1 chunk. */ + private static class Sample + { + String name; + byte [] pcm; + int sampleRate; + boolean hasLoop; + int loopStart; + int loopEnd; + int rootKey; + } + + + /** + * Constructor. + * + * @param notifier The notifier + */ + public Emulator4Creator (final INotifier notifier) + { + super ("E-mu Emulator IV", "E4B", notifier, EmptySettingsUI.INSTANCE); + } + + + /** {@inheritDoc} */ + @Override + public void createPreset (final File destinationFolder, final IMultisampleSource multisampleSource) throws IOException + { + this.writeBank (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.writeBank (destinationFolder, multisampleSources, libraryName); + } + + + /** + * Write one bank file for the given sources. + * + * @param destinationFolder Where to create the bank file + * @param multisampleSources The sources to convert, each becomes one preset + * @param name The bank name + * @throws IOException Could not write the bank + */ + private void writeBank (final File destinationFolder, final List multisampleSources, final String name) throws IOException + { + final File outputFile = this.createUniqueFilename (destinationFolder, createSafeFilename (name), "e4b"); + this.notifier.log ("IDS_NOTIFY_STORING", outputFile.getAbsolutePath ()); + + final List samples = new ArrayList<> (); + final Map sampleIndicesByContent = new HashMap<> (); + final Set usedSampleNames = new HashSet<> (); + final List presetBodies = new ArrayList<> (); + final List presetNames = new ArrayList<> (); + + for (final IMultisampleSource multisampleSource: multisampleSources) + { + if (presetBodies.size () >= Emulator4Constants.MAX_PRESETS) + { + this.notifier.logError ("IDS_E4B_TOO_MANY_PRESETS", multisampleSource.getName ()); + break; + } + + final List zones = new ArrayList<> (); + for (final IGroup group: multisampleSource.getNonEmptyGroups (true)) + zones.addAll (group.getSampleZones ()); + + final List voices = new ArrayList<> (); + int numMixedDown = 0; + for (final ISampleZone zone: zones) + { + final int sampleIndex = this.addSample (zone, samples, sampleIndicesByContent, usedSampleNames); + if (sampleIndex == 0) + continue; + if (sampleIndex < 0) + numMixedDown++; + voices.add (createVoice (zone, Math.abs (sampleIndex))); + } + if (numMixedDown > 0) + this.notifier.log ("IDS_E4B_MIXED_TO_MONO", Integer.toString (numMixedDown), multisampleSource.getName ()); + if (voices.isEmpty ()) + { + this.notifier.logError ("IDS_E4B_NO_ZONES", multisampleSource.getName ()); + continue; + } + + presetBodies.add (createPresetBody (presetBodies.size (), multisampleSource.getName (), voices)); + presetNames.add (multisampleSource.getName ()); + } + + if (presetBodies.isEmpty ()) + return; + this.writeFile (outputFile, presetBodies, presetNames, samples); + this.notifier.log ("IDS_NOTIFY_PROGRESS_DONE"); + } + + + /** + * Convert the sample of a zone to 16-bit mono PCM and add it to the bank samples, re-using an + * already added sample with identical content. + * + * @param zone The zone + * @param samples The samples collected so far + * @param sampleIndicesByContent The 1-based indices of the collected samples by their content + * @param usedSampleNames All sample names used so far + * @return The 1-based index of the sample, negated if the sample was newly mixed down from + * stereo; 0 if the zone must be skipped + * @throws IOException Could not convert the sample data + */ + private int addSample (final ISampleZone zone, final List samples, final Map sampleIndicesByContent, final Set usedSampleNames) throws IOException + { + final Optional sampleData = zone.getSampleData (); + if (sampleData.isEmpty ()) + { + this.notifier.logError (IDS_NOTIFY_ERR_MISSING_SAMPLE_DATA, zone.getName ()); + return 0; + } + + final WaveFile waveFile = AudioFileUtils.convertToWav (sampleData.get (), DESTINATION_FORMAT); + final int numChannels = waveFile.getFormatChunk ().getNumberOfChannels (); + if (numChannels > 2) + { + this.notifier.logError ("IDS_NOTIFY_ERR_MONO", Integer.toString (numChannels), zone.getName ()); + return 0; + } + final int sampleRate = waveFile.getFormatChunk ().getSampleRate (); + + final byte [] wavData = waveFile.getDataChunk ().getData (); + final boolean isStereo = numChannels == 2; + final byte [] pcm = isStereo ? mixToMono (wavData) : wavData; + final int numFrames = pcm.length / 2; + if (numFrames <= 0) + return 0; + + // The loop is a property of the sample in this format. Backwards loops cannot be + // expressed; an alternating loop is written as a forward loop + boolean hasLoop = false; + int loopStart = 0; + int loopEnd = 0; + for (final ISampleLoop loop: zone.getLoops ()) + if (loop.getType () == LoopType.FORWARDS || loop.getType () == LoopType.ALTERNATING) + { + loopStart = Math.clamp (loop.getStart (), 0, numFrames - 1); + loopEnd = Math.clamp (loop.getEnd (), loopStart + 1, numFrames); + hasLoop = true; + break; + } + + final int rootKey = Math.clamp (zone.getKeyRoot () < 0 ? zone.getKeyLow () : zone.getKeyRoot (), 0, 127); + + // Re-use an already written sample with identical content and parameters, e.g. when the + // same sample is mapped to several key ranges or velocity layers + final Object contentKey = List.of (ByteBuffer.wrap (pcm), Integer.valueOf (sampleRate), Boolean.valueOf (hasLoop), Integer.valueOf (loopStart), Integer.valueOf (loopEnd), Integer.valueOf (rootKey)); + final Integer existingIndex = sampleIndicesByContent.get (contentKey); + if (existingIndex != null) + return existingIndex.intValue (); + + if (samples.size () >= Emulator4Constants.MAX_SAMPLES) + { + this.notifier.logError ("IDS_E4B_TOO_MANY_SAMPLES", zone.getName ()); + return 0; + } + + final Sample sample = new Sample (); + // The root note suffix (e.g. '_C3') is appended to the name; shorten the base name so the + // suffix always fits into the 16 characters + final String suffix = Emulator4Constants.formatNoteSuffix (rootKey); + sample.name = createUniqueSampleName (zone.getName (), usedSampleNames, Emulator4Constants.NAME_LENGTH - suffix.length ()) + suffix; + sample.pcm = pcm; + sample.sampleRate = sampleRate; + sample.hasLoop = hasLoop; + sample.loopStart = loopStart; + sample.loopEnd = loopEnd; + sample.rootKey = rootKey; + samples.add (sample); + + final int index = samples.size (); + sampleIndicesByContent.put (contentKey, Integer.valueOf (index)); + return isStereo ? -index : index; + } + + + /** + * Mix interleaved 16-bit stereo PCM down to mono. + * + * @param stereo The interleaved little-endian stereo data + * @return The mono data + */ + private static byte [] mixToMono (final byte [] stereo) + { + final int numFrames = stereo.length / 4; + final byte [] mono = new byte [numFrames * 2]; + for (int i = 0; i < numFrames; i++) + { + final int left = stereo[i * 4 + 1] << 8 | stereo[i * 4] & 0xFF; + final int right = stereo[i * 4 + 3] << 8 | stereo[i * 4 + 2] & 0xFF; + final int value = (left + right) / 2; + mono[i * 2] = (byte) (value & 0xFF); + mono[i * 2 + 1] = (byte) (value >> 8 & 0xFF); + } + return mono; + } + + + /** + * Create one voice block with a single zone entry from a zone. + * + * @param zone The zone + * @param sampleIndex The 1-based index of the sample of the zone + * @return The voice block + */ + private static byte [] createVoice (final ISampleZone zone, final int sampleIndex) + { + final byte [] voice = new byte [Emulator4Constants.VOICE_SIZE + Emulator4Constants.ZONE_ENTRY_SIZE]; + + final int keyLow = Math.clamp (zone.getKeyLow (), 0, 127); + final int keyHigh = Math.clamp (zone.getKeyHigh (), keyLow, 127); + final int velocityLow = Math.clamp (zone.getVelocityLow (), 0, 127); + final int velocityHigh = Math.clamp (zone.getVelocityHigh (), velocityLow, 127); + + // The voice parameters. The offset at [2] is how the hardware locates the next voice + Emulator4Constants.putU16BE (voice, 2, Emulator4Constants.VOICE_SIZE + Emulator4Constants.ZONE_ENTRY_SIZE); + voice[4] = 1; + voice[7] = 0x64; + // The voice level key window and velocity range must mirror the span of its zones, + // otherwise velocity layers play stacked instead of switched + voice[14] = (byte) keyLow; + voice[17] = (byte) keyHigh; + voice[18] = (byte) velocityLow; + voice[21] = (byte) velocityHigh; + voice[25] = 0x7F; + + // Tuning: the integer part goes to the coarse tune, the rest to the fine tune (in 1/64 + // semitone units); the key transpose at [34] stays 0 + final double tuning = zone.getTuning (); + final int semitones = Math.clamp ((int) Math.round (tuning), -72, 24); + voice[35] = (byte) semitones; + voice[36] = (byte) Math.clamp (Math.round ((tuning - semitones) * 64.0), -64, 63); + voice[38] = (byte) (zone.getKeyTracking () == 0 ? 1 : 0); + voice[51] = (byte) 0x80; + voice[54] = (byte) Math.clamp (Math.round (zone.getGain ()), -96, 24); + + // The filter setting; without a filter write the EOS bypass state (4-pole low-pass, + // fully open, no resonance) + final Optional optFilter = zone.getFilter (); + final IFilter filter = optFilter.isPresent () ? optFilter.get () : null; + if (filter == null) + { + voice[58] = 0x00; + voice[60] = (byte) 0xFF; + voice[61] = 0x00; + } + else + { + voice[58] = (byte) getFilterTypeCode (filter); + voice[60] = (byte) Emulator4Constants.hertzToCutoff (filter.getCutoff ()); + voice[61] = (byte) Math.clamp (Math.round (filter.getResonance () * 127), 0, 127); + } + + // The primary zone table with the amplitude envelope, the filter envelope and the + // default LFO settings + System.arraycopy (Emulator4Constants.PRIMARY_ZONE_TEMPLATE, 0, voice, Emulator4Constants.VOICE_PZT_OFFSET, Emulator4Constants.PRIMARY_ZONE_TEMPLATE.length); + writeAmplitudeEnvelope (voice, zone); + final double filterEnvelopeDepth = filter == null ? 0 : writeFilterEnvelope (voice, filter); + + // The modulation cord table: start from the EOS factory default cord set (required for + // non-transpose voices to be recognized) and set the depths of the used routings + System.arraycopy (Emulator4Constants.MOD_CORD_TEMPLATE, 0, voice, Emulator4Constants.VOICE_MOD_OFFSET, Emulator4Constants.MOD_CORD_TEMPLATE.length); + final double velocityDepth = zone.getAmplitudeVelocityModulator ().getDepth (); + voice[Emulator4Constants.VOICE_MOD_OFFSET + Emulator4Constants.MOD_VELOCITY_AMOUNT] = (byte) Math.clamp (Math.round (velocityDepth * 127), 0, 127); + if (filterEnvelopeDepth != 0) + voice[Emulator4Constants.VOICE_MOD_OFFSET + Emulator4Constants.MOD_FILTER_ENVELOPE_AMOUNT] = (byte) Math.clamp (Math.round (filterEnvelopeDepth * 127), -127, 127); + if (filter != null && filter.getCutoffKeyTracking () != 0) + voice[Emulator4Constants.VOICE_MOD_OFFSET + Emulator4Constants.MOD_KEY_TRACKING_AMOUNT] = (byte) Math.clamp (Math.round (filter.getCutoffKeyTracking () / Emulator4Constants.FULL_KEY_TRACKING * 127), -127, 127); + + // The zone entry + final int entryOffset = Emulator4Constants.VOICE_SIZE; + voice[entryOffset + 2] = (byte) keyLow; + voice[entryOffset + 5] = (byte) keyHigh; + voice[entryOffset + 6] = (byte) velocityLow; + voice[entryOffset + 9] = (byte) velocityHigh; + Emulator4Constants.putU16BE (voice, entryOffset + 10, sampleIndex); + voice[entryOffset + 14] = (byte) Math.clamp (zone.getKeyRoot () < 0 ? keyLow : zone.getKeyRoot (), 0, 127); + + return voice; + } + + + /** + * Write the amplitude envelope of a zone into the primary zone table of a voice. The 6-stage + * EOS envelope uses the standard ADSR mapping: attack 1 rises to full level, decay 1 falls to + * the sustain level which decay 2 holds, release 1 falls to silence. + * + * @param voice The voice block + * @param zone The zone + */ + private static void writeAmplitudeEnvelope (final byte [] voice, final ISampleZone zone) + { + final IEnvelope envelope = zone.getAmplitudeEnvelopeModulator ().getSource (); + final int offset = Emulator4Constants.VOICE_PZT_OFFSET; + + final double sustainLevel = envelope.getSustainLevel (); + final int sustain = sustainLevel < 0 ? 127 : (int) Math.clamp (Math.round (sustainLevel * 127), 0, 127); + final double attackTime = envelope.getAttackTime (); + final double holdTime = envelope.getHoldTime (); + final double decayTime = envelope.getDecayTime (); + final double releaseTime = envelope.getReleaseTime (); + + // A hold stage is expressed with the attack 2 stage: it moves to the same (full) level as + // attack 1 and therefore plateaus for its rate time + voice[offset] = (byte) (attackTime < 0 ? 0 : Emulator4Constants.envelopeTimeToRate (attackTime)); + voice[offset + 1] = 127; + voice[offset + 2] = (byte) (holdTime < 0 ? 0 : Emulator4Constants.envelopeTimeToRate (holdTime)); + voice[offset + 3] = 127; + voice[offset + 4] = (byte) (decayTime < 0 ? 0 : Emulator4Constants.envelopeTimeToRate (decayTime)); + voice[offset + 5] = (byte) sustain; + voice[offset + 6] = 0; + voice[offset + 7] = (byte) sustain; + voice[offset + 8] = (byte) (releaseTime < 0 ? Emulator4Constants.DEFAULT_RELEASE_RATE : Emulator4Constants.envelopeTimeToRate (releaseTime)); + voice[offset + 9] = 0; + voice[offset + 10] = 0; + voice[offset + 11] = 0; + } + + + /** + * Write the filter envelope of a zone into the primary zone table of a voice. The envelope + * shape is always stored at full scale; its depth and direction is the amount of the filter + * envelope to cutoff modulation cord, which the caller writes into the cord table. + * + * @param voice The voice block + * @param filter The filter + * @return The depth of the filter envelope modulation (-1..1), 0 if there is none + */ + private static double writeFilterEnvelope (final byte [] voice, final IFilter filter) + { + final IEnvelopeModulator modulator = filter.getCutoffEnvelopeModulator (); + final double depth = modulator.getDepth (); + if (depth == 0) + return 0; + + final IEnvelope envelope = modulator.getSource (); + final int offset = Emulator4Constants.VOICE_PZT_OFFSET; + final double sustainLevel = envelope.getSustainLevel (); + final int sustain = sustainLevel < 0 ? 127 : (int) Math.clamp (Math.round (sustainLevel * 127), 0, 127); + final double attackTime = envelope.getAttackTime (); + final double decayTime = envelope.getDecayTime (); + final double releaseTime = envelope.getReleaseTime (); + + voice[offset + 14] = (byte) (attackTime < 0 ? 0 : Emulator4Constants.envelopeTimeToRate (attackTime)); + voice[offset + 15] = 127; + voice[offset + 16] = 0; + voice[offset + 17] = 127; + voice[offset + 18] = (byte) (decayTime < 0 ? 0 : Emulator4Constants.envelopeTimeToRate (decayTime)); + voice[offset + 19] = (byte) sustain; + voice[offset + 20] = 0; + voice[offset + 21] = (byte) sustain; + voice[offset + 22] = (byte) (releaseTime < 0 ? Emulator4Constants.DEFAULT_RELEASE_RATE : Emulator4Constants.envelopeTimeToRate (releaseTime)); + voice[offset + 23] = 0; + voice[offset + 24] = 0; + voice[offset + 25] = 0; + + return Math.clamp (depth, -1, 1); + } + + + /** + * Map a model filter to the EOS filter type byte. The byte encodes the filter group in the + * upper bits and the slope variant in the lower 3 bits. + * + * @param filter The filter + * @return The filter type byte + */ + private static int getFilterTypeCode (final IFilter filter) + { + final int poles = filter.getPoles (); + return switch (filter.getType ()) + { + case LOW_PASS -> poles <= 2 ? 0x01 : poles >= 6 ? 0x02 : 0x00; + case HIGH_PASS -> poles >= 4 ? 0x09 : 0x08; + case BAND_PASS -> poles >= 4 ? 0x11 : 0x10; + case BAND_REJECTION -> 0x12; + }; + } + + + /** + * Create the body of an E4P1 preset chunk from its voices. + * + * @param presetIndex The 0-based index of the preset in the bank + * @param name The preset name + * @param voices The voice blocks + * @return The chunk body + */ + private static byte [] createPresetBody (final int presetIndex, final String name, final List voices) + { + int voicesSize = 0; + for (final byte [] voice: voices) + voicesSize += voice.length; + + // Only the last voice of a preset is followed by 2 trailing zero bytes + final byte [] body = new byte [Emulator4Constants.PRESET_HEADER_SIZE + voicesSize + 2]; + Emulator4Constants.putU16BE (body, 0, presetIndex); + Emulator4Constants.encodeName (body, 2, name); + body[19] = 0x52; + Emulator4Constants.putU16BE (body, 20, voices.size ()); + body[28] = 0x78; + if (voices.size () > 1) + { + body[41] = 0x04; + body[43] = 0x01; + } + body[52] = 0x52; + body[53] = 0x23; + body[54] = 0x00; + body[55] = 0x7E; + body[56] = (byte) 0xFF; + body[57] = (byte) 0xFF; + body[58] = (byte) 0xFF; + body[59] = (byte) 0xFF; + + int offset = Emulator4Constants.PRESET_HEADER_SIZE; + for (final byte [] voice: voices) + { + System.arraycopy (voice, 0, body, offset, voice.length); + offset += voice.length; + } + return body; + } + + + /** + * Create the header of an E3S1 sample chunk. All offsets are byte offsets relative to the + * start of the 92 byte EOS sample struct, which begins after the 2 byte sample index. + * + * @param sample The sample + * @param sampleIndex The 1-based index of the sample in the bank + * @return The header + */ + private static byte [] createSampleHeader (final Sample sample, final int sampleIndex) + { + final byte [] header = new byte [Emulator4Constants.SAMPLE_HEADER_SIZE]; + final int structSize = Emulator4Constants.SAMPLE_STRUCT_SIZE; + final int endOffset = structSize + sample.pcm.length - 2; + + Emulator4Constants.putU16BE (header, 0, sampleIndex); + Emulator4Constants.encodeName (header, 2, sample.name); + Emulator4Constants.putU32LE (header, 22, structSize); + Emulator4Constants.putU32LE (header, 30, endOffset); + if (sample.hasLoop) + { + Emulator4Constants.putU32LE (header, 38, (long) sample.loopStart * 2 + structSize); + Emulator4Constants.putU32LE (header, 46, Math.min ((long) sample.loopEnd * 2 + structSize, endOffset)); + } + else + { + Emulator4Constants.putU32LE (header, 38, structSize); + Emulator4Constants.putU32LE (header, 46, endOffset); + } + Emulator4Constants.putU32LE (header, 54, sample.sampleRate); + Emulator4Constants.putU16LE (header, 60, sample.hasLoop ? Emulator4Constants.OPTIONS_MONO_LOOP : Emulator4Constants.OPTIONS_MONO); + Emulator4Constants.putU32LE (header, 62, structSize); + return header; + } + + + /** + * Assemble and write the bank file. + * + * @param outputFile The file to write + * @param presetBodies The bodies of the preset chunks + * @param presetNames The names of the presets, for the table of contents + * @param samples The samples + * @throws IOException Could not write the file + */ + private void writeFile (final File outputFile, final List presetBodies, final List presetNames, final List samples) throws IOException + { + // Calculate the chunk offsets. Chunks are word aligned; only sample chunks can have an + // odd size since all other chunk sizes are even + final int numTocEntries = 1 + presetBodies.size () + samples.size (); + final int tocChunkSize = 8 + numTocEntries * Emulator4Constants.TOC_ENTRY_SIZE; + + int position = 12 + tocChunkSize; + final int e4maOffset = position; + position += 8 + Emulator4Constants.E4MA_SIZE; + + final int [] presetOffsets = new int [presetBodies.size ()]; + for (int i = 0; i < presetBodies.size (); i++) + { + presetOffsets[i] = position; + position += 8 + presetBodies.get (i).length; + } + + final int [] sampleOffsets = new int [samples.size ()]; + final int [] sampleBodySizes = new int [samples.size ()]; + for (int i = 0; i < samples.size (); i++) + { + sampleOffsets[i] = position; + sampleBodySizes[i] = Emulator4Constants.SAMPLE_HEADER_SIZE + samples.get (i).pcm.length; + position += 8 + sampleBodySizes[i] + sampleBodySizes[i] % 2; + } + + final byte [] masterSetup = Emulator4Constants.getDefaultMasterSetup (); + position += 8 + masterSetup.length; + + // The FORM size uses the EOS convention which excludes the 4 byte form type, so it is 4 + // less than the standard IFF value and ends inside the trailing zeros of the EMSt chunk + final long formSize = position - 12L; + + // The table of contents lists all chunks except the trailing EMSt + final byte [] toc = new byte [numTocEntries * Emulator4Constants.TOC_ENTRY_SIZE]; + createTocEntry (toc, 0, Emulator4Constants.E4MA_TAG, Emulator4Constants.E4MA_SIZE, e4maOffset, 0, "Multimap"); + for (int i = 0; i < presetBodies.size (); i++) + createTocEntry (toc, 1 + i, Emulator4Constants.PRESET_TAG, presetBodies.get (i).length, presetOffsets[i], i, presetNames.get (i)); + for (int i = 0; i < samples.size (); i++) + createTocEntry (toc, 1 + presetBodies.size () + i, Emulator4Constants.SAMPLE_TAG, sampleBodySizes[i], sampleOffsets[i], i + 1, samples.get (i).name); + + final byte [] multimap = new byte [Emulator4Constants.E4MA_SIZE]; + for (int i = 0; i < Emulator4Constants.E4MA_SIZE; i++) + multimap[i] = Emulator4Constants.E4MA_ENTRY[i % Emulator4Constants.E4MA_ENTRY.length]; + + try (final OutputStream out = new BufferedOutputStream (Files.newOutputStream (outputFile.toPath ()))) + { + out.write (Emulator4Constants.FORM_MAGIC); + writeU32BE (out, formSize); + out.write (Emulator4Constants.FORM_TYPE); + + writeChunkHeader (out, Emulator4Constants.TOC_TAG, toc.length); + out.write (toc); + writeChunkHeader (out, Emulator4Constants.E4MA_TAG, multimap.length); + out.write (multimap); + for (final byte [] presetBody: presetBodies) + { + writeChunkHeader (out, Emulator4Constants.PRESET_TAG, presetBody.length); + out.write (presetBody); + } + for (int i = 0; i < samples.size (); i++) + { + final Sample sample = samples.get (i); + writeChunkHeader (out, Emulator4Constants.SAMPLE_TAG, sampleBodySizes[i]); + out.write (createSampleHeader (sample, i + 1)); + out.write (sample.pcm); + if (sampleBodySizes[i] % 2 == 1) + out.write (0); + } + writeChunkHeader (out, Emulator4Constants.EMST_TAG, masterSetup.length); + out.write (masterSetup); + } + } + + + /** + * Fill one 32 byte entry of the table of contents. + * + * @param toc The table of contents + * @param entryIndex The index of the entry + * @param tag The chunk tag + * @param dataSize The size of the chunk data + * @param fileOffset The absolute offset of the chunk tag in the file + * @param index The index of the chunk: 0 for the multimap, the 0-based preset index or the + * 1-based sample index + * @param name The name of the chunk + */ + private static void createTocEntry (final byte [] toc, final int entryIndex, final byte [] tag, final int dataSize, final int fileOffset, final int index, final String name) + { + final int offset = entryIndex * Emulator4Constants.TOC_ENTRY_SIZE; + System.arraycopy (tag, 0, toc, offset, 4); + Emulator4Constants.putU32BE (toc, offset + 4, dataSize); + Emulator4Constants.putU32BE (toc, offset + 8, fileOffset); + Emulator4Constants.putU16BE (toc, offset + 12, index); + Emulator4Constants.encodeName (toc, offset + 14, name); + // The bytes at [30] (null) and [31] (MIDI program, 0 = any) stay 0 + } + + + /** + * Write an IFF chunk header (tag and big-endian size). + * + * @param out The output stream + * @param tag The chunk tag + * @param size The size of the chunk data + * @throws IOException Could not write + */ + private static void writeChunkHeader (final OutputStream out, final byte [] tag, final int size) throws IOException + { + out.write (tag); + writeU32BE (out, size); + } + + + /** + * Write a big-endian 32 bit value. + * + * @param out The output stream + * @param value The value + * @throws IOException Could not write + */ + private static void writeU32BE (final OutputStream out, final long value) throws IOException + { + out.write ((int) (value >> 24 & 0xFF)); + out.write ((int) (value >> 16 & 0xFF)); + out.write ((int) (value >> 8 & 0xFF)); + out.write ((int) (value & 0xFF)); + } + + + private static String createUniqueSampleName (final String zoneName, final Set usedSampleNames, final int maxLength) + { + String name = zoneName.trim (); + if (name.length () > maxLength) + name = name.substring (0, maxLength); + int counter = 1; + while (!usedSampleNames.add (name)) + { + counter++; + final String suffix = Integer.toString (counter); + final String base = name.substring (0, Math.min (name.length (), maxLength - suffix.length ())); + name = base + suffix; + } + return name; + } +} diff --git a/src/main/java/de/mossgrabers/convertwithmoss/format/emu/emulator4/Emulator4Detector.java b/src/main/java/de/mossgrabers/convertwithmoss/format/emu/emulator4/Emulator4Detector.java new file mode 100644 index 00000000..2226d8c4 --- /dev/null +++ b/src/main/java/de/mossgrabers/convertwithmoss/format/emu/emulator4/Emulator4Detector.java @@ -0,0 +1,475 @@ +// 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.emu.emulator4; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import de.mossgrabers.convertwithmoss.core.IMultisampleSource; +import de.mossgrabers.convertwithmoss.core.INotifier; +import de.mossgrabers.convertwithmoss.core.detector.AbstractDetector; +import de.mossgrabers.convertwithmoss.core.model.IEnvelope; +import de.mossgrabers.convertwithmoss.core.model.IEnvelopeModulator; +import de.mossgrabers.convertwithmoss.core.model.IFilter; +import de.mossgrabers.convertwithmoss.core.model.IGroup; +import de.mossgrabers.convertwithmoss.core.model.ISampleLoop; +import de.mossgrabers.convertwithmoss.core.model.ISampleZone; +import de.mossgrabers.convertwithmoss.core.model.enumeration.FilterType; +import de.mossgrabers.convertwithmoss.core.model.enumeration.LoopType; +import de.mossgrabers.convertwithmoss.core.model.implementation.DefaultAudioMetadata; +import de.mossgrabers.convertwithmoss.core.model.implementation.DefaultEnvelope; +import de.mossgrabers.convertwithmoss.core.model.implementation.DefaultFilter; +import de.mossgrabers.convertwithmoss.core.model.implementation.DefaultGroup; +import de.mossgrabers.convertwithmoss.core.model.implementation.DefaultSampleLoop; +import de.mossgrabers.convertwithmoss.core.model.implementation.DefaultSampleZone; +import de.mossgrabers.convertwithmoss.core.model.implementation.InMemorySampleData; +import de.mossgrabers.convertwithmoss.core.settings.MetadataSettingsUI; +import de.mossgrabers.tools.FileUtils; + + +/** + * Detects E-mu Emulator IV bank files (*.e4b). A bank contains up to 1000 presets and 1000 + * samples; every preset becomes one multi-sample source. A preset is a list of voices, each of + * which maps a set of zones (key/velocity ranges referencing a sample) and carries the tuning, + * volume, filter and envelope settings for them; every voice becomes one group. The format was + * reverse-engineered by the mpc2emu project, see documentation/design/E4B_FORMAT.md. + * + * @author Jürgen Moßgraber + */ +public class Emulator4Detector extends AbstractDetector +{ + private static final Pattern NOTE_SUFFIX_PATTERN = Pattern.compile ("_([A-G]#?)(-?\\d+)$"); + + + /** Holds the parsed information of one E3S1 sample chunk. */ + private static class Sample + { + String name; + InMemorySampleData sampleData; + int numFrames; + int rootKey; + boolean hasLoop; + int loopStart; + int loopEnd; + } + + + /** + * Constructor. + * + * @param notifier The notifier + */ + public Emulator4Detector (final INotifier notifier) + { + super ("E-mu Emulator IV", "E4B", notifier, new MetadataSettingsUI ("E4B"), ".e4b"); + } + + + /** {@inheritDoc} */ + @Override + public List readPresetFile (final File sourceFile) + { + if (this.waitForDelivery ()) + return Collections.emptyList (); + + try + { + final byte [] data = Files.readAllBytes (sourceFile.toPath ()); + return this.parseBank (sourceFile, data); + } + catch (final IOException ex) + { + this.notifier.logError ("IDS_NOTIFY_ERR_LOAD_FILE", ex); + return Collections.emptyList (); + } + } + + + /** + * Parse a bank file and create one multi-sample source per preset. + * + * @param sourceFile The bank file + * @param data The content of the file + * @return The multi-sample sources + */ + private List parseBank (final File sourceFile, final byte [] data) + { + if (data.length < 12 || !Emulator4Constants.hasMagic (data, 0, Emulator4Constants.FORM_MAGIC) || !Emulator4Constants.hasMagic (data, 8, Emulator4Constants.FORM_TYPE)) + { + this.notifier.logError ("IDS_E4B_NOT_A_BANK", sourceFile.getName ()); + return Collections.emptyList (); + } + + // Walk the chunks sequentially instead of trusting the TOC offsets, which is more robust + // against third-party files. Note that the FORM size uses the EOS convention (4 less than + // standard IFF) and therefore ends inside the trailing EMSt chunk, which is not needed + final List presetChunks = new ArrayList<> (); + final Map samplesByIndex = new HashMap<> (); + final Set usedNames = new HashSet<> (); + int position = 12; + while (position + 8 <= data.length) + { + final long size = Emulator4Constants.getU32BE (data, position + 4); + final long end = position + 8 + size; + if (size < 0 || end > data.length) + break; + if (Emulator4Constants.hasMagic (data, position, Emulator4Constants.PRESET_TAG)) + { + final byte [] body = new byte [(int) size]; + System.arraycopy (data, position + 8, body, 0, (int) size); + presetChunks.add (body); + } + else if (Emulator4Constants.hasMagic (data, position, Emulator4Constants.SAMPLE_TAG)) + this.parseSample (data, position + 8, (int) size, samplesByIndex, usedNames); + // TOC1, E4Ma and EMSt chunks are ignored + position = (int) (end + (size % 2 == 0 ? 0 : 1)); + } + + final List results = new ArrayList<> (); + for (final byte [] presetChunk: presetChunks) + { + final IMultisampleSource multisampleSource = this.parsePreset (sourceFile, presetChunk, samplesByIndex); + if (multisampleSource != null) + results.add (multisampleSource); + } + if (results.isEmpty ()) + this.notifier.logError ("IDS_E4B_NO_PRESETS", sourceFile.getName ()); + else + this.notifier.log ("IDS_E4B_READING_BANK", sourceFile.getName (), Integer.toString (results.size ()), Integer.toString (samplesByIndex.size ())); + return results; + } + + + /** + * Parse an E3S1 sample chunk. The chunk holds a 94 byte header (all fields little-endian + * except the sample index) followed by 16-bit little-endian mono PCM data. Loop positions are + * stored as byte offsets relative to the 92 byte EOS sample struct. + * + * @param data The bank content + * @param offset The offset of the chunk body + * @param size The size of the chunk body + * @param samplesByIndex Where to add the parsed sample by its 1-based index + * @param usedNames All sample names used so far, to make the zone names unique + */ + private void parseSample (final byte [] data, final int offset, final int size, final Map samplesByIndex, final Set usedNames) + { + if (size < Emulator4Constants.SAMPLE_HEADER_SIZE) + { + this.notifier.logError ("IDS_E4B_MALFORMED_SAMPLE", Integer.toString (size)); + return; + } + + final int sampleIndex = Emulator4Constants.getU16BE (data, offset); + final String displayName = Emulator4Constants.decodeName (data, offset + 2); + final long loopStartOffset = Emulator4Constants.getU32LE (data, offset + 38); + final long loopEndOffset = Emulator4Constants.getU32LE (data, offset + 46); + final int sampleRate = (int) Emulator4Constants.getU32LE (data, offset + 54); + final int options = Emulator4Constants.getU16LE (data, offset + 60); + + final int pcmLength = (size - Emulator4Constants.SAMPLE_HEADER_SIZE) / 2 * 2; + final int numFrames = pcmLength / 2; + if (numFrames <= 0 || sampleRate <= 0) + { + this.notifier.logError ("IDS_E4B_MALFORMED_SAMPLE", displayName); + return; + } + final byte [] pcm = new byte [pcmLength]; + System.arraycopy (data, offset + Emulator4Constants.SAMPLE_HEADER_SIZE, pcm, 0, pcmLength); + + final Sample sample = new Sample (); + sample.sampleData = new InMemorySampleData (new DefaultAudioMetadata (1, sampleRate, 16, numFrames), pcm); + sample.numFrames = numFrames; + + // The root note is conventionally appended to the name, e.g. 'Piano_C3' for MIDI note 60. + // The zone entries carry the authoritative root key, this one is only the fallback + String baseName = displayName; + sample.rootKey = 60; + final Matcher matcher = NOTE_SUFFIX_PATTERN.matcher (displayName); + if (matcher.find ()) + { + final int midiNote = Emulator4Constants.lookupNote (matcher.group (1), Integer.parseInt (matcher.group (2))); + if (midiNote >= 0) + { + baseName = displayName.substring (0, matcher.start ()); + sample.rootKey = midiNote; + } + } + // Prefer the suffix-stripped base name; on a collision keep the full display name and as + // the last resort append the unique sample index + String name = baseName.isBlank () ? displayName : baseName; + if (!usedNames.add (name)) + { + name = displayName; + if (!usedNames.add (name)) + { + name = displayName + " " + sampleIndex; + usedNames.add (name); + } + } + sample.name = name; + + if ((options & Emulator4Constants.OPTION_LOOP) > 0) + { + sample.loopStart = (int) (loopStartOffset - Emulator4Constants.SAMPLE_STRUCT_SIZE) / 2; + sample.loopEnd = Math.min ((int) (loopEndOffset - Emulator4Constants.SAMPLE_STRUCT_SIZE) / 2, numFrames); + sample.hasLoop = sample.loopStart >= 0 && sample.loopStart < numFrames && sample.loopEnd > sample.loopStart; + } + + samplesByIndex.put (Integer.valueOf (sampleIndex), sample); + } + + + /** + * Parse an E4P1 preset chunk into a multi-sample source. Every voice of the preset becomes one + * group; the voice parameters (tuning, volume, filter, envelopes, modulation cords) are + * applied to all zones of the voice. + * + * @param sourceFile The bank file + * @param body The content of the preset chunk + * @param samplesByIndex The samples of the bank by their 1-based index + * @return The multi-sample source or null if the preset contains no usable zones + */ + private IMultisampleSource parsePreset (final File sourceFile, final byte [] body, final Map samplesByIndex) + { + if (body.length < Emulator4Constants.PRESET_HEADER_SIZE) + return null; + + final String presetName = Emulator4Constants.decodeName (body, 2); + final int numVoices = Emulator4Constants.getU16BE (body, 20); + + final List groups = new ArrayList<> (); + int offset = Emulator4Constants.PRESET_HEADER_SIZE; + for (int voiceIndex = 0; voiceIndex < numVoices; voiceIndex++) + { + if (offset + Emulator4Constants.VOICE_SIZE > body.length) + { + this.notifier.logError ("IDS_E4B_MALFORMED_PRESET", presetName); + break; + } + + // The offset of the end of the zone table relative to the voice start is how the + // hardware locates the next voice; it also implies the number of zones + final int zoneTableEnd = Emulator4Constants.getU16BE (body, offset + 2); + final int numZones = (zoneTableEnd - Emulator4Constants.VOICE_SIZE) / Emulator4Constants.ZONE_ENTRY_SIZE; + if (zoneTableEnd < Emulator4Constants.VOICE_SIZE || offset + Emulator4Constants.VOICE_SIZE + (long) numZones * Emulator4Constants.ZONE_ENTRY_SIZE > body.length) + { + this.notifier.logError ("IDS_E4B_MALFORMED_PRESET", presetName); + break; + } + + final IGroup group = new DefaultGroup ("Voice " + (voiceIndex + 1)); + this.parseVoice (body, offset, numZones, presetName, samplesByIndex, group); + if (!group.getSampleZones ().isEmpty ()) + groups.add (group); + + offset += Emulator4Constants.VOICE_SIZE + numZones * Emulator4Constants.ZONE_ENTRY_SIZE; + // Only the last voice is followed by 2 trailing zero bytes but they do not matter here + } + + if (groups.isEmpty ()) + return null; + return this.createMultisampleSource (sourceFile, presetName.isBlank () ? FileUtils.getNameWithoutType (sourceFile) : presetName, groups); + } + + + /** + * Parse one voice block and add its zones to the given group. + * + * @param body The content of the preset chunk + * @param offset The offset of the voice block + * @param numZones The number of zone entries of the voice + * @param presetName The name of the preset, for error messages + * @param samplesByIndex The samples of the bank by their 1-based index + * @param group Where to add the created zones + */ + private void parseVoice (final byte [] body, final int offset, final int numZones, final String presetName, final Map samplesByIndex, final IGroup group) + { + // Per-voice tuning: key transpose and coarse tune in semitones, fine tune in 1/64 + // semitone units. All three simply offset the playback pitch of the zones + final double tuning = body[offset + 34] + body[offset + 35] + body[offset + 36] / 64.0; + final boolean isFixedPitch = body[offset + 38] == 1; + final int volume = body[offset + 54]; + + // The modulation cord table provides the depths of the fixed routings + final int modOffset = offset + Emulator4Constants.VOICE_MOD_OFFSET; + double velocityToAmplitude = 0; + double filterEnvelopeDepth = 0; + double filterKeyTracking = 0; + for (int slot = 0; slot < 20; slot++) + { + final int source = body[modOffset + slot * 4] & 0xFF; + final int destination = body[modOffset + slot * 4 + 1] & 0xFF; + final int amount = body[modOffset + slot * 4 + 2]; + if (amount == 0) + continue; + // Velocity sources: 0x0A add, 0x0B centered, 0x0C subtract + if (destination == 0x40 && source >= 0x0A && source <= 0x0C) + velocityToAmplitude = Math.clamp (Math.abs (amount) / 127.0, 0, 1); + else if (destination == 0x38 && source == 0x50) + filterEnvelopeDepth = Math.clamp (amount / 127.0, -1, 1); + else if (destination == 0x38 && source == 0x08) + filterKeyTracking = Math.clamp (amount / 127.0 * Emulator4Constants.FULL_KEY_TRACKING, 0, 1); + } + + // The amplitude envelope: 6 rate/level stages in the primary zone table of which the + // standard ADSR mapping uses attack 1, decay 1 (its level is the sustain) and release 1 + final int pztOffset = offset + Emulator4Constants.VOICE_PZT_OFFSET; + final IEnvelope amplitudeEnvelope = new DefaultEnvelope (); + amplitudeEnvelope.setAttackTime (Emulator4Constants.envelopeRateToTime (body[pztOffset] & 0xFF)); + // An attack 2 stage with the same level as attack 1 is a plateau, which is a hold stage + final double holdTime = Emulator4Constants.envelopeRateToTime (body[pztOffset + 2] & 0xFF); + if (holdTime > 0 && body[pztOffset + 1] == body[pztOffset + 3]) + amplitudeEnvelope.setHoldTime (holdTime); + amplitudeEnvelope.setDecayTime (Emulator4Constants.envelopeRateToTime (body[pztOffset + 4] & 0xFF)); + amplitudeEnvelope.setSustainLevel (Math.clamp (body[pztOffset + 5] / 127.0, 0, 1)); + amplitudeEnvelope.setReleaseTime (Emulator4Constants.envelopeRateToTime (body[pztOffset + 8] & 0xFF)); + + final IFilter filter = createFilter (body, offset, pztOffset, filterEnvelopeDepth, filterKeyTracking); + + for (int zoneIndex = 0; zoneIndex < numZones; zoneIndex++) + { + final int entryOffset = offset + Emulator4Constants.VOICE_SIZE + zoneIndex * Emulator4Constants.ZONE_ENTRY_SIZE; + final int sampleIndex = Emulator4Constants.getU16BE (body, entryOffset + 10); + final Sample sample = samplesByIndex.get (Integer.valueOf (sampleIndex)); + if (sample == null) + { + this.notifier.logError ("IDS_E4B_SAMPLE_MISSING", Integer.toString (sampleIndex), presetName); + continue; + } + + final int keyLow = body[entryOffset + 2] & 0xFF; + final int keyHigh = body[entryOffset + 5] & 0xFF; + final ISampleZone zone = new DefaultSampleZone (sample.name, Math.min (keyLow, 127), Math.min (keyHigh, 127)); + zone.setSampleData (sample.sampleData); + + final int velocityLow = body[entryOffset + 6] & 0xFF; + final int velocityHigh = body[entryOffset + 9] & 0xFF; + if (velocityLow <= velocityHigh && velocityHigh > 0) + { + zone.setVelocityLow (Math.max (1, velocityLow)); + zone.setVelocityHigh (Math.min (127, velocityHigh)); + } + + final int rootKey = body[entryOffset + 14] & 0xFF; + zone.setKeyRoot (rootKey > 0 && rootKey < 128 ? rootKey : sample.rootKey); + zone.setStart (0); + zone.setStop (sample.numFrames); + zone.setTuning (tuning); + zone.setGain (volume); + if (isFixedPitch) + zone.setKeyTracking (0); + + if (sample.hasLoop) + { + final ISampleLoop loop = new DefaultSampleLoop (); + loop.setType (LoopType.FORWARDS); + loop.setStart (sample.loopStart); + loop.setEnd (sample.loopEnd); + zone.getLoops ().add (loop); + } + + zone.getAmplitudeEnvelopeModulator ().setSource (amplitudeEnvelope); + zone.getAmplitudeVelocityModulator ().setDepth (velocityToAmplitude); + if (filter != null) + zone.setFilter (filter); + + group.addSampleZone (zone); + } + } + + + /** + * Create the filter of a voice. The 'wide open' default (4-pole low-pass at full frequency + * without resonance, envelope or key tracking) is the EOS bypass state and creates no filter. + * The effect and morph filter types of the EOS (phasers, flangers, vocal formants, EQ morphs) + * have no model equivalent and create no filter either. + * + * @param body The content of the preset chunk + * @param offset The offset of the voice block + * @param pztOffset The offset of the primary zone table of the voice + * @param filterEnvelopeDepth The depth of the filter envelope to cutoff modulation (-1..1) + * @param filterKeyTracking The key tracking of the filter cutoff (0..1) + * @return The filter or null if the voice does not use one + */ + private static IFilter createFilter (final byte [] body, final int offset, final int pztOffset, final double filterEnvelopeDepth, final double filterKeyTracking) + { + final int filterType = body[offset + 58] & 0xFF; + final int cutoff = body[offset + 60] & 0xFF; + final int resonance = body[offset + 61] & 0xFF; + + final FilterType type; + final int poles; + switch (filterType) + { + case 0x00: + type = FilterType.LOW_PASS; + poles = 4; + break; + case 0x01: + type = FilterType.LOW_PASS; + poles = 2; + break; + case 0x02: + type = FilterType.LOW_PASS; + poles = 6; + break; + case 0x08: + type = FilterType.HIGH_PASS; + poles = 2; + break; + case 0x09: + type = FilterType.HIGH_PASS; + poles = 4; + break; + case 0x10: + type = FilterType.BAND_PASS; + poles = 2; + break; + case 0x11: + type = FilterType.BAND_PASS; + poles = 4; + break; + case 0x12: + // 'Contrary band-pass' is the closest EOS type to a notch + type = FilterType.BAND_REJECTION; + poles = 2; + break; + default: + return null; + } + + // A fully open low-pass without any modulation is the bypass state + if (filterType == 0x00 && cutoff == 255 && resonance == 0 && filterEnvelopeDepth == 0 && filterKeyTracking == 0) + return null; + + final IFilter filter = new DefaultFilter (type, poles, Emulator4Constants.cutoffToHertz (cutoff), Math.clamp (resonance / 127.0, 0, 1)); + filter.setCutoffKeyTracking (filterKeyTracking); + + if (filterEnvelopeDepth != 0) + { + final IEnvelope envelope = new DefaultEnvelope (); + envelope.setAttackTime (Emulator4Constants.envelopeRateToTime (body[pztOffset + 14] & 0xFF)); + envelope.setDecayTime (Emulator4Constants.envelopeRateToTime (body[pztOffset + 18] & 0xFF)); + envelope.setSustainLevel (Math.clamp (body[pztOffset + 19] / 127.0, 0, 1)); + envelope.setReleaseTime (Emulator4Constants.envelopeRateToTime (body[pztOffset + 22] & 0xFF)); + + final IEnvelopeModulator cutoffModulator = filter.getCutoffEnvelopeModulator (); + cutoffModulator.setSource (envelope); + cutoffModulator.setDepth (filterEnvelopeDepth); + } + + return filter; + } +} diff --git a/src/main/resources/Strings.properties b/src/main/resources/Strings.properties index 67dd9be7..ba281d92 100644 --- a/src/main/resources/Strings.properties +++ b/src/main/resources/Strings.properties @@ -382,6 +382,17 @@ IDS_S3P_INVALID_MIDI_MESSAGE=This is not a proper S1000/S3000 MIDI SysEx message IDS_S3P_SAMPLE_INFO_MISSING=The sample does not contain sample information: %1\n IDS_S3P_BROKEN_WAV=Could not read WAV file: %1\n +IDS_E4B_NOT_A_BANK=The file '%1' is not an Emulator IV bank.\n +IDS_E4B_READING_BANK=Read bank '%1': %2 preset(s), %3 sample(s).\n +IDS_E4B_NO_PRESETS=No presets with sample zones found in '%1'.\n +IDS_E4B_MALFORMED_SAMPLE=Skipping malformed sample chunk: %1.\n +IDS_E4B_MALFORMED_PRESET=The voice data of preset '%1' is malformed. Some voices were skipped.\n +IDS_E4B_SAMPLE_MISSING=Sample with index %1 referenced by preset '%2' not found. Skipped.\n +IDS_E4B_MIXED_TO_MONO=%1 stereo sample(s) of '%2' were mixed down to mono.\n +IDS_E4B_NO_ZONES=No convertible sample zones found in '%1'. Skipped.\n +IDS_E4B_TOO_MANY_SAMPLES=All 1000 sample slots of the bank are in use. Sample '%1' skipped.\n +IDS_E4B_TOO_MANY_PRESETS=All 1000 preset slots of the bank are in use. '%1' skipped.\n + IDS_KURZWEIL_READING=Reading %1 '%2'.\n IDS_KURZWEIL_KEYMAP_MISSING=Keymap with ID %1 referenced by program '%2' not found. Skipped.\n IDS_KURZWEIL_SAMPLE_MISSING=Sample with ID %1 referenced by keymap '%2' not found. Skipped.\n From 62964638cafa5cbf14dce2b77d036c0d726582e2 Mon Sep 17 00:00:00 2001 From: Douglas Carmichael Date: Fri, 24 Jul 2026 11:48:14 -0400 Subject: [PATCH 02/12] E-mu Emulator IV: added the feature matrix rows and limited written sample rates to the 48 kHz EOS maximum The Read/Write row pair in SupportedFeaturesSampleFormats.ods documents the supported features. Written samples with a higher sample rate are now down-sampled to 48 kHz (the maximum EOS playback rate) with the zone and loop positions moved accordingly; lower rates are kept as before. --- documentation/README-FORMATS.md | 2 +- .../SupportedFeaturesSampleFormats.ods | Bin 44479 -> 43408 bytes documentation/design/E4B_FORMAT.md | 3 ++- .../emu/emulator4/Emulator4Creator.java | 8 +++++++- 4 files changed, 10 insertions(+), 3 deletions(-) diff --git a/documentation/README-FORMATS.md b/documentation/README-FORMATS.md index 817e7c34..13fa0c50 100644 --- a/documentation/README-FORMATS.md +++ b/documentation/README-FORMATS.md @@ -281,7 +281,7 @@ The E-mu Emulator IV series (Emulator 4, E4X, E4XT, E4K, e-Synth, e-6400 and the When reading, every preset of a bank becomes one multi-sample and every voice becomes a group. Names, key and velocity ranges, root keys, tuning, volume, loops, the amplitude envelope with its velocity modulation, the filter (type, cutoff, resonance, key tracking and envelope with its depth) and the 16-bit sample data are read. The EOS effect filter types (phasers, flangers, vocal formants, EQ morphs) have no model equivalent, such voices are converted without a filter. -When writing, each multi-sample becomes one preset; a library collects all multi-samples into a single bank (up to 1000 presets and 1000 samples). Every zone is written as its own voice, which keeps all per-zone settings. Samples are stored as 16-bit mono PCM with their original sample rate; stereo samples are mixed down to mono. Identical samples mapped to multiple zones are stored only once. Since EOS only has a sample-level forward loop, alternating loops are written as forward loops and only the first loop of a zone is kept. Written banks validate against the reference parser of the mpc2emu project but have not been verified on real hardware yet. +When writing, each multi-sample becomes one preset; a library collects all multi-samples into a single bank (up to 1000 presets and 1000 samples). Every zone is written as its own voice, which keeps all per-zone settings. Samples are stored as 16-bit mono PCM with their original sample rate (rates above 48kHz, the EOS maximum, are down-sampled); stereo samples are mixed down to mono. Identical samples mapped to multiple zones are stored only once. Since EOS only has a sample-level forward loop, alternating loops are written as forward loops and only the first loop of a zone is kept. Written banks validate against the reference parser of the mpc2emu project but have not been verified on real hardware yet. ## Elektron Tonverk diff --git a/documentation/SupportedFeaturesSampleFormats.ods b/documentation/SupportedFeaturesSampleFormats.ods index a60f4bc92e15fa4b2b00f3b73dd7867725e238fa..69f462f27d8534b6a63c0a74c3f92595b5c92c72 100644 GIT binary patch delta 28087 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 T!~6vye!QX}z`(9c|GxV_MZ2Xo delta 29107 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 { + /** The maximum sample playback rate of the EOS samplers. */ + private static final int MAX_SAMPLE_RATE = 48000; + private static final DestinationAudioFormat DESTINATION_FORMAT = new DestinationAudioFormat (new int [] { 16 - }, -1, false); + }, MAX_SAMPLE_RATE, false); /** Holds one de-duplicated sample to be written as an E3S1 chunk. */ @@ -132,6 +135,9 @@ private void writeBank (final File destinationFolder, final List zones = new ArrayList<> (); for (final IGroup group: multisampleSource.getNonEmptyGroups (true)) zones.addAll (group.getSampleZones ()); From 12ec0dd96baf302c6115e1e10637d5fd6e6fb91b Mon Sep 17 00:00:00 2001 From: Douglas Carmichael Date: Fri, 24 Jul 2026 12:29:18 -0400 Subject: [PATCH 03/12] E-mu Emulator IV: read banks directly from EOS CD-ROM and hard disk images The EOS samplers use a proprietary disk filesystem on their CD-ROMs and hard disks (they read no standard filesystem from CD at all). Banks can now be read directly from such images (.iso, .img, .hda - e.g. dumps of commercial E-mu CD-ROM libraries or images for SCSI emulators like the ZuluSCSI), both via the E4B source format and via the generic ISO/IMG source format. All geometry is taken from the superblock, so both the CD-ROM and the hard disk variant of the filesystem are read. Files which are not Emulator IV banks (e.g. EIII banks on discs of the older samplers, which use the same filesystem) are skipped. Validated against images generated by the mpc2emu reference builder (3 banks each as CD and as 1 GB hard disk image): all 198 presets read through both source formats with sample PCM byte-identical to reading the plain bank files. --- documentation/CHANGELOG.md | 1 + documentation/README-FORMATS.md | 5 +- documentation/design/E4B_FORMAT.md | 35 +++ .../format/emu/emulator4/Emu3DiskImage.java | 249 ++++++++++++++++++ .../emu/emulator4/Emulator4Detector.java | 90 ++++++- .../format/iso/IsoDetector.java | 11 +- .../convertwithmoss/format/iso/IsoFormat.java | 3 + .../format/iso/IsoFormatIdentifier.java | 4 + src/main/resources/Strings.properties | 2 + 9 files changed, 384 insertions(+), 16 deletions(-) create mode 100644 src/main/java/de/mossgrabers/convertwithmoss/format/emu/emulator4/Emu3DiskImage.java diff --git a/documentation/CHANGELOG.md b/documentation/CHANGELOG.md index 70ae586f..92727f4f 100644 --- a/documentation/CHANGELOG.md +++ b/documentation/CHANGELOG.md @@ -3,6 +3,7 @@ ## 19.2.0 * New: Added support for the E-mu Emulator IV bank format (E4B). Written banks have not been tested on real hardware yet. +* New: E-mu Emulator IV banks can also be read directly from CD-ROM and hard disk images of the EOS samplers (ISO, IMG, HDA), including via the ISO/IMG source format. ## 19.1.0 diff --git a/documentation/README-FORMATS.md b/documentation/README-FORMATS.md index 13fa0c50..42abf0d0 100644 --- a/documentation/README-FORMATS.md +++ b/documentation/README-FORMATS.md @@ -279,6 +279,8 @@ There is no write support. The E-mu Emulator IV series (Emulator 4, E4X, E4XT, E4K, e-Synth, e-6400 and the other EOS samplers, 1994-2002) stores its banks in single *.e4b* files which contain all presets, their parameters and the sample data. A preset layers several voices; each voice maps a set of zones (a key/velocity range referencing a sample) and carries the tuning, volume, filter, envelope and modulation settings for them. The format is not documented by E-mu, the layout was reverse-engineered by the mpc2emu project from hardware-saved E4XT banks and commercial EOS CD-ROMs (see *documentation/design/E4B_FORMAT.md*). +Banks can be read from single *.e4b* files and also directly from CD-ROM and hard disk images of the EOS samplers (*.iso*, *.img*, *.hda* - e.g. images for SCSI emulators like the ZuluSCSI or dumps of commercial E-mu CD-ROMs), which use the proprietary E-mu disk filesystem. All banks of an image are read; files of the older EIII samplers, which use the same filesystem for their banks, are skipped. + When reading, every preset of a bank becomes one multi-sample and every voice becomes a group. Names, key and velocity ranges, root keys, tuning, volume, loops, the amplitude envelope with its velocity modulation, the filter (type, cutoff, resonance, key tracking and envelope with its depth) and the 16-bit sample data are read. The EOS effect filter types (phasers, flangers, vocal formants, EQ morphs) have no model equivalent, such voices are converted without a filter. When writing, each multi-sample becomes one preset; a library collects all multi-samples into a single bank (up to 1000 presets and 1000 samples). Every zone is written as its own voice, which keeps all per-zone settings. Samples are stored as 16-bit mono PCM with their original sample rate (rates above 48kHz, the EOS maximum, are down-sampled); stereo samples are mixed down to mono. Identical samples mapped to multiple zones are stored only once. Since EOS only has a sample-level forward loop, alternating loops are written as forward loops and only the first loop of a zone is kept. Written banks validate against the reference parser of the mpc2emu project but have not been verified on real hardware yet. @@ -373,9 +375,10 @@ Note that this will not work with IIx or earlier versions despite the same VC fi ## ISO/IMG Files -Searches for files ending with *.ISO or *.IMG. Currently, the following formats can be handled: +Searches for files ending with *.ISO, *.IMG or *.HDA. Currently, the following formats can be handled: * [Akai S1000/3000](#akai-s1000s3000-series-disk-image) +* [E-mu Emulator IV](#e-mu-emulator-iv) * [Akai MPC2000/MPC2000XL](#akai-mpc2000mpc2000xlmpc3000) * [Ensoniq EPS/ASR](#ensoniq-epseps16asr-10) (only *.ISO) * [Roland S-50 series](#roland-s-50-series) diff --git a/documentation/design/E4B_FORMAT.md b/documentation/design/E4B_FORMAT.md index 2eb9a6d3..22b13e71 100644 --- a/documentation/design/E4B_FORMAT.md +++ b/documentation/design/E4B_FORMAT.md @@ -183,6 +183,41 @@ EOS has exactly one loop type (forward, on/off at the sample level). A forward loop shorter than ~84 frames plays an octave low on the hardware (the E4XT doubles it silently). +## EOS disk filesystem (CD-ROM and hard disk images) + +The EOS samplers do not read standard filesystems from CD-ROM (and only EOS +4.7+ reads FAT hard disks); their media use E-mu's own filesystem, known from +the emu3fs Linux kernel module and reverse-engineered for the Emulator IV by +the mpc2emu project (`docs/EMU3_ISO_FORMAT.md` and +`docs/re_procedures/emu_hdd_fs.md` in its source tree). All values are +little-endian; everything is addressed in 512 byte blocks: + +``` +Block 0: superblock - magic 'EMU3', then u32 fields: total blocks - 1, + root start/blocks, dir-content start/blocks, cluster list ('FAT') + start/blocks, data start block, total clusters; byte 0x28 = cluster + size (bytes = 1 << (15 + value)); checksum at 0x1FE = sum of the + 255 u16 words of bytes 0x000-0x1FD (checked by the firmware!) +Block 1: byte 0 = next free dir-content block +FAT: u16 per cluster: next cluster of the chain, 0x7FFF = last, + entry 0 = 0x8000 (reserved) +Root: 32-byte folder entries: name[16], 0, type (0x40 = user folder, + 0x80 = hard disk 'Default Folder'), 7 x u16 dir-content block + indices (0xFFFF = unused) +Dircon: 16 x 32-byte file entries per block: name[16], 0, id, u16 start + cluster (1-based), u16 clusters, u16 blocks used in the last + cluster (a partial block counts as a whole one!), u16 bytes used + in the last block, type (0x81), 5 bytes props (0x00 'E4B0') +Data: cluster c starts at block 'data start' + (c - 1) * blocks/cluster +``` + +The CD variant uses the fixed geometry FAT=2+5, root=7+4, dircon=11+125, +data=136 with cluster sizes of 512 KB/1 MB/2 MB (the smallest which keeps the +cluster count under the 1279 the 5 FAT blocks can hold; 512 KB is preferred as +larger clusters caused read errors on hardware); the hard disk variant uses +FAT=2+4, root=6+7, dircon=13+169, data=182. The reader takes all geometry +from the superblock and therefore reads both. + ## Mapping decisions of the converter * **Reading:** every preset becomes one multi-sample source, every voice one diff --git a/src/main/java/de/mossgrabers/convertwithmoss/format/emu/emulator4/Emu3DiskImage.java b/src/main/java/de/mossgrabers/convertwithmoss/format/emu/emulator4/Emu3DiskImage.java new file mode 100644 index 00000000..ab23e22a --- /dev/null +++ b/src/main/java/de/mossgrabers/convertwithmoss/format/emu/emulator4/Emu3DiskImage.java @@ -0,0 +1,249 @@ +// 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.emu.emulator4; + +import java.io.File; +import java.io.IOException; +import java.io.RandomAccessFile; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + + +/** + * Reads the proprietary E-mu disk filesystem which the EOS samplers use on their CD-ROMs and hard + * disks (and which their CD-ROM/SCSI emulators like the ZuluSCSI serve from raw image files). It + * is a simple FAT-like filesystem of 512 byte blocks: a superblock, a cluster chain list, a root + * directory of folders and dir-content blocks with the file entries. The layout was + * reverse-engineered by the mpc2emu project from commercial E-mu CD-ROMs and EOS formatted disks, + * cross-checked against the emu3fs Linux kernel module; see documentation/design/E4B_FORMAT.md. + * All geometry is read from the superblock, so both the CD-ROM and the hard disk variant of the + * filesystem are supported. + * + * @author Jürgen Moßgraber + */ +public class Emu3DiskImage +{ + /** The magic bytes of the superblock. */ + public static final byte [] MAGIC = "EMU3".getBytes (); + + private static final int BLOCK_SIZE = 512; + private static final int ENTRY_SIZE = 32; + /** The number of dir-content block references of a folder entry. */ + private static final int FOLDER_BLOCK_LIST = 7; + /** The end-of-chain marker in the cluster list. */ + private static final int LAST_CLUSTER = 0x7FFF; + /** The folder markers: 0x40 = user folder (CD), 0x80 = 'Default Folder' (hard disk). */ + private static final int FOLDER_TYPE_USER = 0x40; + private static final int FOLDER_TYPE_DEFAULT = 0x80; + + + /** A file read from the image. */ + public static class ImageFile + { + private final String name; + private final byte [] content; + + + /** + * Constructor. + * + * @param name The name of the file + * @param content The content of the file + */ + public ImageFile (final String name, final byte [] content) + { + this.name = name; + this.content = content; + } + + + /** + * Get the name of the file. + * + * @return The name + */ + public String getName () + { + return this.name; + } + + + /** + * Get the content of the file. + * + * @return The content + */ + public byte [] getContent () + { + return this.content; + } + } + + + /** + * Private constructor since this is a utility class. + */ + private Emu3DiskImage () + { + // Intentionally empty + } + + + /** + * Check if the data starts with the magic bytes of the filesystem superblock. + * + * @param data The first bytes of a file, at least 4 + * @return True if it is an E-mu disk image + */ + public static boolean isEmu3Image (final byte [] data) + { + return Emulator4Constants.hasMagic (data, 0, MAGIC); + } + + + /** + * Read all files from the image. The files are not interpreted; the caller decides by their + * content what they are (EOS images contain E4B banks, images of the older EIII samplers + * contain EIII banks). + * + * @param imageFile The image file + * @return The files of the image + * @throws IOException The image could not be read or is malformed + */ + public static List readFiles (final File imageFile) throws IOException + { + try (final RandomAccessFile file = new RandomAccessFile (imageFile, "r")) + { + final byte [] superblock = readBlocks (file, 0, 1); + if (!isEmu3Image (superblock)) + throw new IOException ("Not an E-mu disk image."); + + final int rootStart = (int) Emulator4Constants.getU32LE (superblock, 0x08); + final int rootBlocks = (int) Emulator4Constants.getU32LE (superblock, 0x0C); + final int fatStart = (int) Emulator4Constants.getU32LE (superblock, 0x18); + final int fatBlocks = (int) Emulator4Constants.getU32LE (superblock, 0x1C); + final int dataStart = (int) Emulator4Constants.getU32LE (superblock, 0x20); + final int clusterSizeExtra = superblock[0x28] & 0xFF; + final long totalBlocks = file.length () / BLOCK_SIZE; + if (rootStart <= 0 || rootBlocks <= 0 || rootBlocks > 64 || fatStart <= 0 || fatBlocks <= 0 || fatBlocks > 64 || dataStart <= 0 || clusterSizeExtra < 1 || clusterSizeExtra > 12 || rootStart + rootBlocks > totalBlocks || fatStart + fatBlocks > totalBlocks) + throw new IOException ("Malformed E-mu disk image superblock."); + + final long clusterBytes = 1L << 15 + clusterSizeExtra; + final int blocksPerCluster = (int) (clusterBytes / BLOCK_SIZE); + + // The cluster chain list ('FAT') + final byte [] fatData = readBlocks (file, fatStart, fatBlocks); + final int [] fat = new int [fatData.length / 2]; + for (int i = 0; i < fat.length; i++) + fat[i] = Emulator4Constants.getU16LE (fatData, i * 2); + + // Collect the dir-content blocks of all folders in the root directory + final byte [] rootData = readBlocks (file, rootStart, rootBlocks); + final List dirContentBlocks = new ArrayList<> (); + for (int offset = 0; offset + ENTRY_SIZE <= rootData.length; offset += ENTRY_SIZE) + { + final int folderType = rootData[offset + 17] & 0xFF; + if (folderType != FOLDER_TYPE_USER && folderType != FOLDER_TYPE_DEFAULT) + continue; + for (int i = 0; i < FOLDER_BLOCK_LIST; i++) + { + final int block = Emulator4Constants.getU16LE (rootData, offset + 18 + i * 2); + if (block > 0 && block != 0xFFFF && block < totalBlocks) + dirContentBlocks.add (Integer.valueOf (block)); + } + } + + // Read the file entries of all dir-content blocks + final List files = new ArrayList<> (); + for (final Integer dirContentBlock: dirContentBlocks) + { + final byte [] entries = readBlocks (file, dirContentBlock.intValue (), 1); + for (int offset = 0; offset + ENTRY_SIZE <= entries.length; offset += ENTRY_SIZE) + { + final ImageFile imageFile2 = readFileEntry (file, entries, offset, fat, dataStart, blocksPerCluster, clusterBytes); + if (imageFile2 != null) + files.add (imageFile2); + } + } + return files; + } + } + + + /** + * Read the file described by one 32 byte dir-content entry. + * + * @param file The image file + * @param entries The content of the dir-content block + * @param offset The offset of the entry in the block + * @param fat The cluster chain list + * @param dataStart The first block of the data area + * @param blocksPerCluster The number of blocks of a cluster + * @param clusterBytes The size of a cluster in bytes + * @return The file or null if the entry is empty or malformed + * @throws IOException Could not read the image + */ + private static ImageFile readFileEntry (final RandomAccessFile file, final byte [] entries, final int offset, final int [] fat, final int dataStart, final int blocksPerCluster, final long clusterBytes) throws IOException + { + final int startCluster = Emulator4Constants.getU16LE (entries, offset + 18); + final int numClusters = Emulator4Constants.getU16LE (entries, offset + 20); + final int lastClusterBlocks = Emulator4Constants.getU16LE (entries, offset + 22); + final int lastBlockBytes = Emulator4Constants.getU16LE (entries, offset + 24); + final int fileType = entries[offset + 26] & 0xFF; + if (fileType == 0 || startCluster < 1 || numClusters < 1 || lastClusterBlocks < 1 && lastBlockBytes == 0) + return null; + + // The size of the data in the last cluster: a partially filled last block still counts + // as a whole block, the used bytes of it are stored separately + final long lastClusterBytes = Math.min ((lastClusterBlocks - 1L) * BLOCK_SIZE + (lastBlockBytes > 0 ? lastBlockBytes : BLOCK_SIZE), clusterBytes); + final long size = (numClusters - 1L) * clusterBytes + lastClusterBytes; + if (size <= 0 || size > Integer.MAX_VALUE) + return null; + + final byte [] content = new byte [(int) size]; + final Set visited = new HashSet<> (); + int cluster = startCluster; + long position = 0; + for (int i = 0; i < numClusters; i++) + { + if (cluster < 1 || cluster >= fat.length || !visited.add (Integer.valueOf (cluster))) + return null; + final long imageOffset = (dataStart + (cluster - 1L) * blocksPerCluster) * BLOCK_SIZE; + final int length = (int) Math.min (clusterBytes, size - position); + if (imageOffset + length > file.length ()) + return null; + file.seek (imageOffset); + file.readFully (content, (int) position, length); + position += length; + if (i < numClusters - 1) + { + final int next = fat[cluster]; + cluster = next == LAST_CLUSTER ? -1 : next; + } + } + + return new ImageFile (Emulator4Constants.decodeName (entries, offset), content); + } + + + /** + * Read a number of 512 byte blocks from the image. + * + * @param file The image file + * @param startBlock The first block to read + * @param numBlocks The number of blocks to read + * @return The data + * @throws IOException Could not read the blocks + */ + private static byte [] readBlocks (final RandomAccessFile file, final int startBlock, final int numBlocks) throws IOException + { + final byte [] data = new byte [numBlocks * BLOCK_SIZE]; + file.seek ((long) startBlock * BLOCK_SIZE); + file.readFully (data); + return data; + } +} diff --git a/src/main/java/de/mossgrabers/convertwithmoss/format/emu/emulator4/Emulator4Detector.java b/src/main/java/de/mossgrabers/convertwithmoss/format/emu/emulator4/Emulator4Detector.java index 2226d8c4..9d69191a 100644 --- a/src/main/java/de/mossgrabers/convertwithmoss/format/emu/emulator4/Emulator4Detector.java +++ b/src/main/java/de/mossgrabers/convertwithmoss/format/emu/emulator4/Emulator4Detector.java @@ -6,12 +6,14 @@ import java.io.File; import java.io.IOException; +import java.io.InputStream; import java.nio.file.Files; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Set; import java.util.regex.Matcher; @@ -40,11 +42,13 @@ /** - * Detects E-mu Emulator IV bank files (*.e4b). A bank contains up to 1000 presets and 1000 - * samples; every preset becomes one multi-sample source. A preset is a list of voices, each of - * which maps a set of zones (key/velocity ranges referencing a sample) and carries the tuning, - * volume, filter and envelope settings for them; every voice becomes one group. The format was - * reverse-engineered by the mpc2emu project, see documentation/design/E4B_FORMAT.md. + * Detects E-mu Emulator IV bank files (*.e4b) as well as CD-ROM and hard disk images of the EOS + * samplers (*.iso, *.img, *.hda) which use the proprietary E-mu disk filesystem and contain such + * banks. A bank contains up to 1000 presets and 1000 samples; every preset becomes one + * multi-sample source. A preset is a list of voices, each of which maps a set of zones + * (key/velocity ranges referencing a sample) and carries the tuning, volume, filter and envelope + * settings for them; every voice becomes one group. The format was reverse-engineered by the + * mpc2emu project, see documentation/design/E4B_FORMAT.md. * * @author Jürgen Moßgraber */ @@ -73,7 +77,7 @@ private static class Sample */ public Emulator4Detector (final INotifier notifier) { - super ("E-mu Emulator IV", "E4B", notifier, new MetadataSettingsUI ("E4B"), ".e4b"); + super ("E-mu Emulator IV", "E4B", notifier, new MetadataSettingsUI ("E4B"), ".e4b", ".iso", ".img", ".hda"); } @@ -86,8 +90,20 @@ public List readPresetFile (final File sourceFile) try { + final byte [] magic = readMagic (sourceFile); + if (Emu3DiskImage.isEmu3Image (magic)) + return this.parseImage (sourceFile); + + if (!Emulator4Constants.hasMagic (magic, 0, Emulator4Constants.FORM_MAGIC)) + { + // Images of other formats are silently ignored, they belong to other detectors + if (sourceFile.getName ().toLowerCase (Locale.US).endsWith (".e4b")) + this.notifier.logError ("IDS_E4B_NOT_A_BANK", sourceFile.getName ()); + return Collections.emptyList (); + } + final byte [] data = Files.readAllBytes (sourceFile.toPath ()); - return this.parseBank (sourceFile, data); + return this.parseBank (sourceFile, sourceFile.getName (), data); } catch (final IOException ex) { @@ -98,17 +114,63 @@ public List readPresetFile (final File sourceFile) /** - * Parse a bank file and create one multi-sample source per preset. + * Parse all banks of an EOS disk image and create one multi-sample source per preset. * - * @param sourceFile The bank file - * @param data The content of the file + * @param sourceFile The image file + * @return The multi-sample sources + * @throws IOException Could not read the image + */ + private List parseImage (final File sourceFile) throws IOException + { + final List results = new ArrayList<> (); + int numBanks = 0; + for (final Emu3DiskImage.ImageFile imageFile: Emu3DiskImage.readFiles (sourceFile)) + { + // Skip files which are not Emulator IV banks, e.g. the banks of the older EIII + // samplers which use the same filesystem + final byte [] content = imageFile.getContent (); + if (!Emulator4Constants.hasMagic (content, 0, Emulator4Constants.FORM_MAGIC) || !Emulator4Constants.hasMagic (content, 8, Emulator4Constants.FORM_TYPE)) + continue; + numBanks++; + results.addAll (this.parseBank (sourceFile, imageFile.getName (), content)); + } + if (numBanks == 0) + this.notifier.logError ("IDS_E4B_NO_BANKS_IN_IMAGE", sourceFile.getName ()); + else + this.notifier.log ("IDS_E4B_READING_IMAGE", sourceFile.getName (), Integer.toString (numBanks)); + return results; + } + + + /** + * Read the first bytes of a file to identify its type. + * + * @param sourceFile The file to read from + * @return The first 4 bytes + * @throws IOException Could not read the file + */ + private static byte [] readMagic (final File sourceFile) throws IOException + { + try (final InputStream in = Files.newInputStream (sourceFile.toPath ())) + { + return in.readNBytes (4); + } + } + + + /** + * Parse a bank and create one multi-sample source per preset. + * + * @param sourceFile The file which contains the bank (the bank file itself or a disk image) + * @param sourceName The name of the bank for logging + * @param data The content of the bank * @return The multi-sample sources */ - private List parseBank (final File sourceFile, final byte [] data) + private List parseBank (final File sourceFile, final String sourceName, final byte [] data) { if (data.length < 12 || !Emulator4Constants.hasMagic (data, 0, Emulator4Constants.FORM_MAGIC) || !Emulator4Constants.hasMagic (data, 8, Emulator4Constants.FORM_TYPE)) { - this.notifier.logError ("IDS_E4B_NOT_A_BANK", sourceFile.getName ()); + this.notifier.logError ("IDS_E4B_NOT_A_BANK", sourceName); return Collections.emptyList (); } @@ -145,9 +207,9 @@ else if (Emulator4Constants.hasMagic (data, position, Emulator4Constants.SAMPLE_ results.add (multisampleSource); } if (results.isEmpty ()) - this.notifier.logError ("IDS_E4B_NO_PRESETS", sourceFile.getName ()); + this.notifier.logError ("IDS_E4B_NO_PRESETS", sourceName); else - this.notifier.log ("IDS_E4B_READING_BANK", sourceFile.getName (), Integer.toString (results.size ()), Integer.toString (samplesByIndex.size ())); + this.notifier.log ("IDS_E4B_READING_BANK", sourceName, Integer.toString (results.size ()), Integer.toString (samplesByIndex.size ())); return results; } diff --git a/src/main/java/de/mossgrabers/convertwithmoss/format/iso/IsoDetector.java b/src/main/java/de/mossgrabers/convertwithmoss/format/iso/IsoDetector.java index e94852ae..94e426d9 100644 --- a/src/main/java/de/mossgrabers/convertwithmoss/format/iso/IsoDetector.java +++ b/src/main/java/de/mossgrabers/convertwithmoss/format/iso/IsoDetector.java @@ -12,6 +12,7 @@ import de.mossgrabers.convertwithmoss.core.INotifier; import de.mossgrabers.convertwithmoss.core.settings.MetadataSettingsUI; import de.mossgrabers.convertwithmoss.format.akai.mpc2000.AkaiMPC2000Detector; +import de.mossgrabers.convertwithmoss.format.emu.emulator4.Emulator4Detector; import de.mossgrabers.convertwithmoss.format.ensoniq.epsasr.EnsoniqEpsAsrDetector; import de.mossgrabers.convertwithmoss.format.roland.s5xx.S5xxDetector; import de.mossgrabers.convertwithmoss.format.roland.s7xx.S770Detector; @@ -27,6 +28,7 @@ public class IsoDetector extends AbstractIsoDetector private static final String IDS_ISO_PROCESSING_FORMAT = "IDS_ISO_PROCESSING_FORMAT"; private final EnsoniqEpsAsrDetector ensoniqDetector; + private final Emulator4Detector emulator4Detector; private final S5xxDetector rolandS5xxDetector; private final S770Detector rolandS7xxDetector; @@ -38,8 +40,9 @@ public class IsoDetector extends AbstractIsoDetector */ public IsoDetector (final INotifier notifier) { - super ("ISO/IMG file", "ISO", notifier, new MetadataSettingsUI ("ISO"), ".iso", ".img", ".out", ".sdk"); + super ("ISO/IMG file", "ISO", notifier, new MetadataSettingsUI ("ISO"), ".iso", ".img", ".out", ".sdk", ".hda"); + this.emulator4Detector = new Emulator4Detector (notifier); this.ensoniqDetector = new EnsoniqEpsAsrDetector (notifier); this.rolandS5xxDetector = new S5xxDetector (notifier); this.rolandS7xxDetector = new S770Detector (notifier); @@ -63,6 +66,12 @@ protected List readPresetFile (final File sourceFile) this.notifier.log (IDS_ISO_PROCESSING_FORMAT, IsoFormat.getName (isoFormat)); return this.processAkaiS1000Disk (sourceFile); + case EMU3: + this.notifier.log (IDS_ISO_PROCESSING_FORMAT, IsoFormat.getName (isoFormat)); + this.emulator4Detector.setSourceFolder (this.sourceFolder); + this.emulator4Detector.setSettings (this.settingsConfiguration); + return this.emulator4Detector.readPresetFile (sourceFile); + case ENSONIQ: this.ensoniqDetector.setSourceFolder (this.sourceFolder); this.ensoniqDetector.setSettings (this.settingsConfiguration); diff --git a/src/main/java/de/mossgrabers/convertwithmoss/format/iso/IsoFormat.java b/src/main/java/de/mossgrabers/convertwithmoss/format/iso/IsoFormat.java index 70e25d7f..2a4fa203 100644 --- a/src/main/java/de/mossgrabers/convertwithmoss/format/iso/IsoFormat.java +++ b/src/main/java/de/mossgrabers/convertwithmoss/format/iso/IsoFormat.java @@ -23,6 +23,8 @@ public enum IsoFormat AKAI_S1000_S1100, /** The format used by Akai S3000. */ AKAI_S3000, + /** The format used by the E-mu EOS samplers (Emulator IV series). */ + EMU3, /** Ensoniq EPS16+/ASR */ ENSONIQ, /** The ISO 9660 format. */ @@ -42,6 +44,7 @@ public enum IsoFormat NAMES.put (AKAI_MPC2000XL, "Akai MPC2000XL"); NAMES.put (AKAI_S1000_S1100, "Akai S1000/S1100 series"); NAMES.put (AKAI_S3000, "Akai S3000/MPC2000 series"); + NAMES.put (EMU3, "E-mu EOS"); NAMES.put (ENSONIQ, "Ensoniq"); NAMES.put (ISO_9660, "ISO 9660"); NAMES.put (ROLAND_S5XX, "Roland S5xx"); diff --git a/src/main/java/de/mossgrabers/convertwithmoss/format/iso/IsoFormatIdentifier.java b/src/main/java/de/mossgrabers/convertwithmoss/format/iso/IsoFormatIdentifier.java index 89dbb295..2d4453ed 100644 --- a/src/main/java/de/mossgrabers/convertwithmoss/format/iso/IsoFormatIdentifier.java +++ b/src/main/java/de/mossgrabers/convertwithmoss/format/iso/IsoFormatIdentifier.java @@ -97,6 +97,10 @@ public static IsoFormat identifyIso (final byte [] data) if (data == null || data.length < MINIMUM_NUMBER_OF_REQUIRED_BYTES) return IsoFormat.UNKNOWN; + // E-mu EOS (Emulator IV series) - the proprietary EMU3 filesystem + if ("EMU3".equals (readString (data, 0, 4))) + return IsoFormat.EMU3; + // Roland S-5xx / S-7xx try { diff --git a/src/main/resources/Strings.properties b/src/main/resources/Strings.properties index ba281d92..be9ed615 100644 --- a/src/main/resources/Strings.properties +++ b/src/main/resources/Strings.properties @@ -383,6 +383,8 @@ IDS_S3P_SAMPLE_INFO_MISSING=The sample does not contain sample information: %1\n IDS_S3P_BROKEN_WAV=Could not read WAV file: %1\n IDS_E4B_NOT_A_BANK=The file '%1' is not an Emulator IV bank.\n +IDS_E4B_READING_IMAGE=Read EOS disk image '%1': %2 bank(s).\n +IDS_E4B_NO_BANKS_IN_IMAGE=No Emulator IV banks found in the disk image '%1'.\n IDS_E4B_READING_BANK=Read bank '%1': %2 preset(s), %3 sample(s).\n IDS_E4B_NO_PRESETS=No presets with sample zones found in '%1'.\n IDS_E4B_MALFORMED_SAMPLE=Skipping malformed sample chunk: %1.\n From 3e5207ad343cf00856d866e8335914ffebdcf9b3 Mon Sep 17 00:00:00 2001 From: Douglas Carmichael Date: Fri, 24 Jul 2026 12:34:39 -0400 Subject: [PATCH 04/12] E-mu Emulator IV: option to write the bank as a CD-ROM image for SCSI CD-ROM emulators The EOS samplers read no standard filesystem from CD-ROM and only EOS 4.7 or later reads FAT hard disks, so for units running older EOS versions an image with the proprietary E-mu disk filesystem is the only way to load banks at all. With the new destination option (house pattern of the DecentSampler 'Create Bundle' checkbox) the created bank is wrapped into such a CD-ROM image (*.iso) which can be copied to the SD card of a SCSI emulator (e.g. as CD1.iso for a ZuluSCSI in CD-ROM mode) and loaded from the emulated CD-ROM drive on any EOS version. The written images are byte-identical to those of the hardware-verified mpc2emu reference builder for the same bank content (verified with a Roland MV-8000 source converted both ways), and they round-trip through the image reading added before. --- documentation/CHANGELOG.md | 1 + documentation/README-FORMATS.md | 4 + .../format/emu/emulator4/Emu3DiskImage.java | 201 ++++++++++++++++++ .../emu/emulator4/Emulator4Creator.java | 79 ++++--- .../emu/emulator4/Emulator4CreatorUI.java | 108 ++++++++++ src/main/resources/Strings.properties | 2 + 6 files changed, 361 insertions(+), 34 deletions(-) create mode 100644 src/main/java/de/mossgrabers/convertwithmoss/format/emu/emulator4/Emulator4CreatorUI.java diff --git a/documentation/CHANGELOG.md b/documentation/CHANGELOG.md index 92727f4f..973cd34c 100644 --- a/documentation/CHANGELOG.md +++ b/documentation/CHANGELOG.md @@ -4,6 +4,7 @@ * New: Added support for the E-mu Emulator IV bank format (E4B). Written banks have not been tested on real hardware yet. * New: E-mu Emulator IV banks can also be read directly from CD-ROM and hard disk images of the EOS samplers (ISO, IMG, HDA), including via the ISO/IMG source format. +* New: The E-mu Emulator IV destination can write the bank as a ready-to-use CD-ROM image for SCSI CD-ROM emulators (e.g. ZuluSCSI), which is the only way to load banks on units running EOS versions before 4.7. ## 19.1.0 diff --git a/documentation/README-FORMATS.md b/documentation/README-FORMATS.md index 42abf0d0..81ccc51f 100644 --- a/documentation/README-FORMATS.md +++ b/documentation/README-FORMATS.md @@ -285,6 +285,10 @@ When reading, every preset of a bank becomes one multi-sample and every voice be When writing, each multi-sample becomes one preset; a library collects all multi-samples into a single bank (up to 1000 presets and 1000 samples). Every zone is written as its own voice, which keeps all per-zone settings. Samples are stored as 16-bit mono PCM with their original sample rate (rates above 48kHz, the EOS maximum, are down-sampled); stereo samples are mixed down to mono. Identical samples mapped to multiple zones are stored only once. Since EOS only has a sample-level forward loop, alternating loops are written as forward loops and only the first loop of a zone is kept. Written banks validate against the reference parser of the mpc2emu project but have not been verified on real hardware yet. +#### Destination Options + +* Create CD-ROM image (.iso) for SCSI CD-ROM emulators: The bank is wrapped into a CD-ROM image (*.iso*) with the proprietary E-mu disk filesystem. Copy the image to the SD card of a SCSI emulator (e.g. rename it to *CD1.iso* for a ZuluSCSI in CD-ROM mode) and load the bank on the sampler from the emulated CD-ROM drive. This works on all EOS versions and on units which cannot read FAT hard disks (EOS before 4.7); a plain bank file instead requires a FAT formatted hard disk and EOS 4.7 or later. + ## Elektron Tonverk The Elektron Tonverk is a dedicated hardware sampler that marks an important milestone for Elektron as its first instrument to support multi-samples. This allows users to map multiple sampled sounds across keys or velocity ranges, creating more expressive and realistic instruments than single-sample playback alone. diff --git a/src/main/java/de/mossgrabers/convertwithmoss/format/emu/emulator4/Emu3DiskImage.java b/src/main/java/de/mossgrabers/convertwithmoss/format/emu/emulator4/Emu3DiskImage.java index ab23e22a..02626446 100644 --- a/src/main/java/de/mossgrabers/convertwithmoss/format/emu/emulator4/Emu3DiskImage.java +++ b/src/main/java/de/mossgrabers/convertwithmoss/format/emu/emulator4/Emu3DiskImage.java @@ -4,9 +4,12 @@ package de.mossgrabers.convertwithmoss.format.emu.emulator4; +import java.io.BufferedOutputStream; import java.io.File; import java.io.IOException; +import java.io.OutputStream; import java.io.RandomAccessFile; +import java.nio.file.Files; import java.util.ArrayList; import java.util.HashSet; import java.util.List; @@ -246,4 +249,202 @@ private static ImageFile readFileEntry (final RandomAccessFile file, final byte file.readFully (data); return data; } + + + //////////////////////////////////////////////////////////////////////////////////// + // Writing - the CD-ROM variant of the filesystem with its fixed geometry, which is + // what the firmware expects of a CD-ROM volume (a computed 'tighter' layout is not + // mountable). Matches the hardware-verified reference builder of the mpc2emu project. + + private static final int FAT_START = 2; + private static final int FAT_BLOCKS = 5; + private static final int ROOT_START = 7; + private static final int ROOT_BLOCKS = 4; + private static final int DIRCON_START = 11; + private static final int DIRCON_BLOCKS = 125; + private static final int DATA_START = 136; + /** 5 FAT blocks hold 1280 entries of which entry 0 is reserved. */ + private static final int MAX_CLUSTERS = FAT_BLOCKS * (BLOCK_SIZE / 2) - 1; + /** The number of files the written single dir-content block can hold. */ + public static final int MAX_FILES = BLOCK_SIZE / ENTRY_SIZE; + + + /** + * Write a CD-ROM image containing the given files. The image can be renamed to e.g. CD1.iso + * on the SD card of a SCSI emulator like the ZuluSCSI to be served as a CD-ROM. + * + * @param outputFile The image file to write + * @param files The files to store, at most {@link #MAX_FILES} + * @throws IOException Could not write the image or the files are too large for one image + */ + public static void writeImage (final File outputFile, final List files) throws IOException + { + if (files.size () > MAX_FILES) + throw new IOException ("Too many files for one image: " + files.size ()); + + // The smallest cluster size (512 KB, 1 MB or 2 MB) which keeps the clusters of all files + // in the FAT. 512 KB is preferred, larger clusters caused read errors on real hardware + int clusterSizeExtra = -1; + long clusterBytes = 0; + long numClusters = 0; + for (int extra = 4; extra <= 6; extra++) + { + clusterBytes = 1L << 15 + extra; + numClusters = 0; + for (final ImageFile file: files) + numClusters += (file.getContent ().length + clusterBytes - 1) / clusterBytes; + if (numClusters <= MAX_CLUSTERS) + { + clusterSizeExtra = extra; + break; + } + } + if (clusterSizeExtra < 0) + throw new IOException ("The files are too large for one image."); + final int blocksPerCluster = (int) (clusterBytes / BLOCK_SIZE); + final long totalBlocks = DATA_START + numClusters * blocksPerCluster; + + try (final OutputStream out = new BufferedOutputStream (Files.newOutputStream (outputFile.toPath ()))) + { + out.write (createSuperblock (totalBlocks, numClusters, clusterSizeExtra)); + + // The padding block after the superblock only holds the next free dir-content block + final byte [] padding = new byte [BLOCK_SIZE]; + padding[0] = DIRCON_START + 1; + out.write (padding); + + out.write (createFat (files, clusterBytes)); + out.write (createRootDirectory ()); + out.write (createDirContent (files, clusterBytes)); + out.write (new byte [(DIRCON_BLOCKS - 1) * BLOCK_SIZE]); + + // The file data, each file padded to a full cluster + for (final ImageFile file: files) + { + final byte [] content = file.getContent (); + out.write (content); + final int pad = (int) ((clusterBytes - content.length % clusterBytes) % clusterBytes); + if (pad > 0) + out.write (new byte [pad]); + } + } + } + + + /** + * Create the superblock. + * + * @param totalBlocks The total number of blocks of the image + * @param numClusters The total number of data clusters + * @param clusterSizeExtra The cluster size (bytes = 1 << (15 + value)) + * @return The 512 byte superblock + */ + private static byte [] createSuperblock (final long totalBlocks, final long numClusters, final int clusterSizeExtra) + { + final byte [] superblock = new byte [BLOCK_SIZE]; + System.arraycopy (MAGIC, 0, superblock, 0, 4); + Emulator4Constants.putU32LE (superblock, 0x04, totalBlocks - 1); + Emulator4Constants.putU32LE (superblock, 0x08, ROOT_START); + Emulator4Constants.putU32LE (superblock, 0x0C, ROOT_BLOCKS); + Emulator4Constants.putU32LE (superblock, 0x10, DIRCON_START); + Emulator4Constants.putU32LE (superblock, 0x14, DIRCON_BLOCKS); + Emulator4Constants.putU32LE (superblock, 0x18, FAT_START); + Emulator4Constants.putU32LE (superblock, 0x1C, FAT_BLOCKS); + Emulator4Constants.putU32LE (superblock, 0x20, DATA_START); + Emulator4Constants.putU32LE (superblock, 0x24, numClusters); + // Flag bytes present in every working reference image; the firmware checks for them + superblock[0x28] = (byte) clusterSizeExtra; + superblock[0x29] = 0x01; + superblock[0x2D] = 0x08; + superblock[0x32] = 0x01; + superblock[0x33] = 0x0D; + // The checksum is verified at mount time; without it the volume does not mount + int checksum = 0; + for (int i = 0; i < 0x1FE; i += 2) + checksum = checksum + Emulator4Constants.getU16LE (superblock, i) & 0xFFFF; + Emulator4Constants.putU16LE (superblock, 0x1FE, checksum); + return superblock; + } + + + /** + * Create the cluster chain list. Every file occupies a sequential run of clusters, starting + * at cluster 1. + * + * @param files The files + * @param clusterBytes The size of a cluster in bytes + * @return The FAT blocks + */ + private static byte [] createFat (final List files, final long clusterBytes) + { + final byte [] fat = new byte [FAT_BLOCKS * BLOCK_SIZE]; + // Entry 0 is the reserved media descriptor + Emulator4Constants.putU16LE (fat, 0, 0x8000); + int cluster = 1; + for (final ImageFile file: files) + { + final int numClusters = (int) ((file.getContent ().length + clusterBytes - 1) / clusterBytes); + for (int i = 0; i < numClusters; i++) + { + Emulator4Constants.putU16LE (fat, cluster * 2, i < numClusters - 1 ? cluster + 1 : LAST_CLUSTER); + cluster++; + } + } + return fat; + } + + + /** + * Create the root directory with the single 'Default Folder' which references the first + * dir-content block. + * + * @return The root directory blocks + */ + private static byte [] createRootDirectory () + { + final byte [] root = new byte [ROOT_BLOCKS * BLOCK_SIZE]; + Emulator4Constants.encodeName (root, 0, "Default Folder"); + root[17] = FOLDER_TYPE_USER; + Emulator4Constants.putU16LE (root, 18, DIRCON_START); + for (int i = 1; i < FOLDER_BLOCK_LIST; i++) + Emulator4Constants.putU16LE (root, 18 + i * 2, 0xFFFF); + return root; + } + + + /** + * Create the first dir-content block with one entry per file. + * + * @param files The files + * @param clusterBytes The size of a cluster in bytes + * @return The dir-content block + */ + private static byte [] createDirContent (final List files, final long clusterBytes) + { + final byte [] block = new byte [BLOCK_SIZE]; + int cluster = 1; + for (int i = 0; i < files.size (); i++) + { + final ImageFile file = files.get (i); + final int offset = i * ENTRY_SIZE; + final long size = file.getContent ().length; + final int numClusters = (int) ((size + clusterBytes - 1) / clusterBytes); + final long lastClusterBytes = size - (numClusters - 1L) * clusterBytes; + final int lastBlockBytes = (int) (lastClusterBytes % BLOCK_SIZE); + // A partially filled last block counts as a whole block, otherwise the firmware + // does not read the tail of the file + final int lastClusterBlocks = (int) (lastClusterBytes / BLOCK_SIZE) + (lastBlockBytes > 0 ? 1 : 0); + + Emulator4Constants.encodeName (block, offset, file.getName ()); + block[offset + 17] = (byte) i; + Emulator4Constants.putU16LE (block, offset + 18, cluster); + Emulator4Constants.putU16LE (block, offset + 20, numClusters); + Emulator4Constants.putU16LE (block, offset + 22, lastClusterBlocks); + Emulator4Constants.putU16LE (block, offset + 24, lastBlockBytes); + block[offset + 26] = (byte) 0x81; + System.arraycopy (Emulator4Constants.FORM_TYPE, 0, block, offset + 28, 4); + cluster += numClusters; + } + return block; + } } diff --git a/src/main/java/de/mossgrabers/convertwithmoss/format/emu/emulator4/Emulator4Creator.java b/src/main/java/de/mossgrabers/convertwithmoss/format/emu/emulator4/Emulator4Creator.java index d6525709..2df0f0de 100644 --- a/src/main/java/de/mossgrabers/convertwithmoss/format/emu/emulator4/Emulator4Creator.java +++ b/src/main/java/de/mossgrabers/convertwithmoss/format/emu/emulator4/Emulator4Creator.java @@ -5,6 +5,7 @@ package de.mossgrabers.convertwithmoss.format.emu.emulator4; import java.io.BufferedOutputStream; +import java.io.ByteArrayOutputStream; import java.io.File; import java.io.IOException; import java.io.OutputStream; @@ -31,7 +32,6 @@ import de.mossgrabers.convertwithmoss.core.model.ISampleZone; import de.mossgrabers.convertwithmoss.core.model.enumeration.FilterType; import de.mossgrabers.convertwithmoss.core.model.enumeration.LoopType; -import de.mossgrabers.convertwithmoss.core.settings.EmptySettingsUI; import de.mossgrabers.convertwithmoss.file.AudioFileUtils; import de.mossgrabers.convertwithmoss.file.wav.WaveFile; @@ -48,7 +48,7 @@ * * @author Jürgen Moßgraber */ -public class Emulator4Creator extends AbstractCreator +public class Emulator4Creator extends AbstractCreator { /** The maximum sample playback rate of the EOS samplers. */ private static final int MAX_SAMPLE_RATE = 48000; @@ -79,7 +79,7 @@ private static class Sample */ public Emulator4Creator (final INotifier notifier) { - super ("E-mu Emulator IV", "E4B", notifier, EmptySettingsUI.INSTANCE); + super ("E-mu Emulator IV", "E4B", notifier, new Emulator4CreatorUI ("E4B")); } @@ -118,7 +118,9 @@ public void createPresetLibrary (final File destinationFolder, final List multisampleSources, final String name) throws IOException { - final File outputFile = this.createUniqueFilename (destinationFolder, createSafeFilename (name), "e4b"); + final boolean writeCdImage = this.settingsConfiguration.writeCdImage (); + final String safeName = createSafeFilename (name); + final File outputFile = this.createUniqueFilename (destinationFolder, safeName, writeCdImage ? "iso" : "e4b"); this.notifier.log ("IDS_NOTIFY_STORING", outputFile.getAbsolutePath ()); final List samples = new ArrayList<> (); @@ -167,7 +169,18 @@ private void writeBank (final File destinationFolder, final List presetBodies, final List presetNames, final List samples) throws IOException + private void writeFile (final OutputStream outputStream, final List presetBodies, final List presetNames, final List samples) throws IOException { // Calculate the chunk offsets. Chunks are word aligned; only sample chunks can have an // odd size since all other chunk sizes are even @@ -596,33 +609,31 @@ private void writeFile (final File outputFile, final List presetBodies, for (int i = 0; i < Emulator4Constants.E4MA_SIZE; i++) multimap[i] = Emulator4Constants.E4MA_ENTRY[i % Emulator4Constants.E4MA_ENTRY.length]; - try (final OutputStream out = new BufferedOutputStream (Files.newOutputStream (outputFile.toPath ()))) + final OutputStream out = outputStream; + out.write (Emulator4Constants.FORM_MAGIC); + writeU32BE (out, formSize); + out.write (Emulator4Constants.FORM_TYPE); + + writeChunkHeader (out, Emulator4Constants.TOC_TAG, toc.length); + out.write (toc); + writeChunkHeader (out, Emulator4Constants.E4MA_TAG, multimap.length); + out.write (multimap); + for (final byte [] presetBody: presetBodies) { - out.write (Emulator4Constants.FORM_MAGIC); - writeU32BE (out, formSize); - out.write (Emulator4Constants.FORM_TYPE); - - writeChunkHeader (out, Emulator4Constants.TOC_TAG, toc.length); - out.write (toc); - writeChunkHeader (out, Emulator4Constants.E4MA_TAG, multimap.length); - out.write (multimap); - for (final byte [] presetBody: presetBodies) - { - writeChunkHeader (out, Emulator4Constants.PRESET_TAG, presetBody.length); - out.write (presetBody); - } - for (int i = 0; i < samples.size (); i++) - { - final Sample sample = samples.get (i); - writeChunkHeader (out, Emulator4Constants.SAMPLE_TAG, sampleBodySizes[i]); - out.write (createSampleHeader (sample, i + 1)); - out.write (sample.pcm); - if (sampleBodySizes[i] % 2 == 1) - out.write (0); - } - writeChunkHeader (out, Emulator4Constants.EMST_TAG, masterSetup.length); - out.write (masterSetup); + writeChunkHeader (out, Emulator4Constants.PRESET_TAG, presetBody.length); + out.write (presetBody); + } + for (int i = 0; i < samples.size (); i++) + { + final Sample sample = samples.get (i); + writeChunkHeader (out, Emulator4Constants.SAMPLE_TAG, sampleBodySizes[i]); + out.write (createSampleHeader (sample, i + 1)); + out.write (sample.pcm); + if (sampleBodySizes[i] % 2 == 1) + out.write (0); } + writeChunkHeader (out, Emulator4Constants.EMST_TAG, masterSetup.length); + out.write (masterSetup); } diff --git a/src/main/java/de/mossgrabers/convertwithmoss/format/emu/emulator4/Emulator4CreatorUI.java b/src/main/java/de/mossgrabers/convertwithmoss/format/emu/emulator4/Emulator4CreatorUI.java new file mode 100644 index 00000000..3755d108 --- /dev/null +++ b/src/main/java/de/mossgrabers/convertwithmoss/format/emu/emulator4/Emulator4CreatorUI.java @@ -0,0 +1,108 @@ +// 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.emu.emulator4; + +import java.util.Map; + +import de.mossgrabers.convertwithmoss.core.INotifier; +import de.mossgrabers.convertwithmoss.core.settings.ICoreTaskSettings; +import de.mossgrabers.tools.ui.BasicConfig; +import de.mossgrabers.tools.ui.panel.BoxPanel; +import javafx.geometry.Orientation; +import javafx.scene.control.CheckBox; +import javafx.scene.layout.Pane; + + +/** + * Settings for the E-mu Emulator IV creator. + * + * @author Jürgen Moßgraber + */ +public class Emulator4CreatorUI implements ICoreTaskSettings +{ + private static final String WRITE_CD_IMAGE = "WriteCdImage"; + + private final String prefix; + private CheckBox writeCdImageCheckBox; + private boolean writeCdImage = false; + + + /** + * Constructor. + * + * @param prefix The prefix to use for the identifier + */ + public Emulator4CreatorUI (final String prefix) + { + this.prefix = prefix; + } + + + /** {@inheritDoc} */ + @Override + public Pane getEditPane () + { + final BoxPanel panel = new BoxPanel (Orientation.VERTICAL); + panel.createSeparator ("@IDS_E4B_OUTPUT_FORMAT"); + this.writeCdImageCheckBox = panel.createCheckBox ("@IDS_E4B_WRITE_CD_IMAGE"); + return panel.getPane (); + } + + + /** {@inheritDoc} */ + @Override + public void loadSettings (final BasicConfig config) + { + this.writeCdImageCheckBox.setSelected (config.getBoolean (this.prefix + WRITE_CD_IMAGE, false)); + } + + + /** {@inheritDoc} */ + @Override + public void saveSettings (final BasicConfig config) + { + config.setBoolean (this.prefix + WRITE_CD_IMAGE, this.writeCdImageCheckBox.isSelected ()); + } + + + /** {@inheritDoc} */ + @Override + public boolean checkSettingsUI (final INotifier notifier) + { + this.writeCdImage = this.writeCdImageCheckBox.isSelected (); + return true; + } + + + /** {@inheritDoc} */ + @Override + public boolean checkSettingsCLI (final INotifier notifier, final Map parameters) + { + this.writeCdImage = "1".equals (parameters.remove (this.prefix + WRITE_CD_IMAGE)); + return true; + } + + + /** {@inheritDoc} */ + @Override + public String [] getCLIParameterNames () + { + return new String [] + { + this.prefix + WRITE_CD_IMAGE + }; + } + + + /** + * Should a CD-ROM image be written instead of a plain bank file? + * + * @return True to write a CD-ROM image + */ + public boolean writeCdImage () + { + return this.writeCdImage; + } +} diff --git a/src/main/resources/Strings.properties b/src/main/resources/Strings.properties index be9ed615..dd6a0b58 100644 --- a/src/main/resources/Strings.properties +++ b/src/main/resources/Strings.properties @@ -394,6 +394,8 @@ IDS_E4B_MIXED_TO_MONO=%1 stereo sample(s) of '%2' were mixed down to mono.\n IDS_E4B_NO_ZONES=No convertible sample zones found in '%1'. Skipped.\n IDS_E4B_TOO_MANY_SAMPLES=All 1000 sample slots of the bank are in use. Sample '%1' skipped.\n IDS_E4B_TOO_MANY_PRESETS=All 1000 preset slots of the bank are in use. '%1' skipped.\n +IDS_E4B_OUTPUT_FORMAT=Output Format +IDS_E4B_WRITE_CD_IMAGE=Create CD-ROM image (.iso) for SCSI CD-ROM emulators IDS_KURZWEIL_READING=Reading %1 '%2'.\n IDS_KURZWEIL_KEYMAP_MISSING=Keymap with ID %1 referenced by program '%2' not found. Skipped.\n From c889128a09fcecbe0091faa44d3e4a21b4e07f46 Mon Sep 17 00:00:00 2001 From: Douglas Carmichael Date: Sat, 25 Jul 2026 10:02:06 -0400 Subject: [PATCH 05/12] E-mu Emulator IV: write the sample pitch offset so samples not at 44.1 kHz play in tune The sampler plays every sample back at its fixed 44100 Hz rate and takes the transposition of a sample stored at a different rate from a signed 1/64 semitone offset at byte 58 of the sample struct, which the creator left at zero. The sample rate field at byte 54, which was the only one written, is informational and inherited from the EOS 3 struct; the firmware does not use it for the pitch. Any written sample not at exactly 44100 Hz therefore played back transposed by the ratio of the two rates - a 22050 Hz sample sounded a full octave too high. The offset is round(768 * log2(rate / 44100)), reverse-engineered from E4XT hardware captures by the mpc2emu project (accurate within +/-2 units over 11025-48000 Hz) and confirmed there on hardware. This was not visible in a conversion round-trip because the detector reads the rate back from the informational field, which was written correctly all along. --- documentation/design/E4B_FORMAT.md | 16 ++++++++++++- .../emu/emulator4/Emulator4Constants.java | 23 +++++++++++++++++++ .../emu/emulator4/Emulator4Creator.java | 4 ++++ 3 files changed, 42 insertions(+), 1 deletion(-) diff --git a/documentation/design/E4B_FORMAT.md b/documentation/design/E4B_FORMAT.md index 22b13e71..47ce58b3 100644 --- a/documentation/design/E4B_FORMAT.md +++ b/documentation/design/E4B_FORMAT.md @@ -175,7 +175,8 @@ are byte offsets relative to the struct start (i.e. 92 = first PCM byte): | 30 | 4 | end = 92 + PCM bytes - 2 | | 38 | 4 | loop start | | 46 | 4 | loop end | -| 54 | 4 | sample rate | +| 54 | 4 | sample rate - informational only, inherited from the EOS 3 struct | +| 58 | 2 | pitch offset in 1/64 semitones (signed) | | 60 | 2 | options: 0x0020 = mono, 0x0031 = mono with forward loop | | 62 | 4 | data offset = 92 | @@ -183,6 +184,19 @@ EOS has exactly one loop type (forward, on/off at the sample level). A forward loop shorter than ~84 frames plays an octave low on the hardware (the E4XT doubles it silently). +The sampler always plays a sample back at 44100 Hz; the playback pitch of a +sample stored at a different rate is set by the pitch offset at 58 and *not* +by the sample rate field at 54, which the firmware ignores for this. The +offset was reverse-engineered by the mpc2emu project from E4XT hardware +captures (the Sample-Rate-Convert function applied to a set of known rates, +the resulting banks diffed against an untouched one) as + + offset = round (768 * log2 (rate / 44100)) + +which holds within ±2 units over 11025-48000 Hz. A sample written with an +offset of 0 therefore plays back transposed by the ratio of its rate to +44100 Hz - a 22050 Hz sample sounds one octave too high. + ## EOS disk filesystem (CD-ROM and hard disk images) The EOS samplers do not read standard filesystems from CD-ROM (and only EOS diff --git a/src/main/java/de/mossgrabers/convertwithmoss/format/emu/emulator4/Emulator4Constants.java b/src/main/java/de/mossgrabers/convertwithmoss/format/emu/emulator4/Emulator4Constants.java index b2f3ddb3..a20ea976 100644 --- a/src/main/java/de/mossgrabers/convertwithmoss/format/emu/emulator4/Emulator4Constants.java +++ b/src/main/java/de/mossgrabers/convertwithmoss/format/emu/emulator4/Emulator4Constants.java @@ -81,6 +81,11 @@ public class Emulator4Constants /** The filter cutoff key tracking of a +100% Key-to-FilterFreq cord in octaves per octave. */ public static final double FULL_KEY_TRACKING = 0.713; + /** The fixed sample playback rate of the EOS samplers, the reference of the pitch offset. */ + public static final int PLAYBACK_RATE = 44100; + /** The resolution of the sample pitch offset: 1/64 semitones, therefore 768 per octave. */ + private static final double PITCH_UNITS_PER_OCTAVE = 768.0; + // The envelope rate to time law was calibrated on E4XT hardware by the mpc2emu project: // seconds = 0.0310 * e^(0.0581 * rate) with rate 0 = instant and rate 127 = ~47 seconds private static final double ENV_RATE_FACTOR = 0.0310; @@ -233,6 +238,24 @@ public static int hertzToCutoff (final double frequency) } + /** + * Calculate the sample pitch offset which sets the playback pitch of a sample. The sampler + * always plays a sample back at {@link #PLAYBACK_RATE}, therefore a sample stored at any other + * rate needs to be transposed by the ratio of the two rates - a sample rate of half the + * playback rate would otherwise sound one octave too high. The sample rate field of the sample + * struct is informational only and is not used for this by the sampler. + * + * @param sampleRate The sample rate of the sample in Hertz + * @return The offset in 1/64 semitones, signed, to be written as a 16 bit value + */ + public static int calculatePitchOffset (final int sampleRate) + { + if (sampleRate <= 0) + return 0; + return (int) Math.round (PITCH_UNITS_PER_OCTAVE * Math.log ((double) sampleRate / PLAYBACK_RATE) / Math.log (2)); + } + + /** * Format the note name suffix which the format appends to sample names to make the root note * visible in hardware browsers, e.g. '_C3' for MIDI note 60. diff --git a/src/main/java/de/mossgrabers/convertwithmoss/format/emu/emulator4/Emulator4Creator.java b/src/main/java/de/mossgrabers/convertwithmoss/format/emu/emulator4/Emulator4Creator.java index 2df0f0de..9d06108c 100644 --- a/src/main/java/de/mossgrabers/convertwithmoss/format/emu/emulator4/Emulator4Creator.java +++ b/src/main/java/de/mossgrabers/convertwithmoss/format/emu/emulator4/Emulator4Creator.java @@ -548,6 +548,10 @@ private static int getFilterTypeCode (final IFilter filter) Emulator4Constants.putU32LE (header, 46, endOffset); } Emulator4Constants.putU32LE (header, 54, sample.sampleRate); + // The playback pitch is not taken from the sample rate field above, which is + // informational only - it is this signed offset in 1/64 semitones relative to the 44100 Hz + // playback rate of the sampler + Emulator4Constants.putU16LE (header, 58, Emulator4Constants.calculatePitchOffset (sample.sampleRate)); Emulator4Constants.putU16LE (header, 60, sample.hasLoop ? Emulator4Constants.OPTIONS_MONO_LOOP : Emulator4Constants.OPTIONS_MONO); Emulator4Constants.putU32LE (header, 62, structSize); return header; From 5225765729bc07a7a3cb5e00e89ebd84f1fe8b06 Mon Sep 17 00:00:00 2001 From: Douglas Carmichael Date: Sat, 25 Jul 2026 10:02:17 -0400 Subject: [PATCH 06/12] E-mu Emulator IV: fix a CD-ROM image entry which made a bank fail to load The 'bytes used in the last block' of a written file entry was derived with a plain remainder of the block size, which is zero whenever the data in a file's last cluster happens to be an exact multiple of 512 bytes. The entry then describes the file one block short and the sampler aborts its load with an end of file error, while every other bank of the same image loads normally. The value is now derived from the block count instead, so it is always in the range of 1 to 512. Every entry which was already correct keeps its exact value - only the case which produced a zero changes. Found and confirmed on an E4XT by the mpc2emu project. The reader keeps accepting a zero as a full block, since images written by other tools can contain it. --- documentation/design/E4B_FORMAT.md | 11 ++++++++++- .../format/emu/emulator4/Emu3DiskImage.java | 8 ++++++-- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/documentation/design/E4B_FORMAT.md b/documentation/design/E4B_FORMAT.md index 47ce58b3..943439bf 100644 --- a/documentation/design/E4B_FORMAT.md +++ b/documentation/design/E4B_FORMAT.md @@ -221,7 +221,8 @@ Root: 32-byte folder entries: name[16], 0, type (0x40 = user folder, Dircon: 16 x 32-byte file entries per block: name[16], 0, id, u16 start cluster (1-based), u16 clusters, u16 blocks used in the last cluster (a partial block counts as a whole one!), u16 bytes used - in the last block, type (0x81), 5 bytes props (0x00 'E4B0') + in the last block (1..512, never 0 - see below), type (0x81), + 5 bytes props (0x00 'E4B0') Data: cluster c starts at block 'data start' + (c - 1) * blocks/cluster ``` @@ -232,6 +233,14 @@ larger clusters caused read errors on hardware); the hard disk variant uses FAT=2+4, root=6+7, dircon=13+169, data=182. The reader takes all geometry from the superblock and therefore reads both. +The 'bytes used in the last block' of a file entry must be counted together +with the block count, so that it is 1..512 and never 0: a file whose last +cluster happens to be an exact multiple of 512 bytes long would otherwise be +described one block short and the firmware aborts its load with an end of file +error (found and confirmed on an E4XT by the mpc2emu project). The reader +accepts a 0 as a full block, since images written by other tools can contain +it. + ## Mapping decisions of the converter * **Reading:** every preset becomes one multi-sample source, every voice one diff --git a/src/main/java/de/mossgrabers/convertwithmoss/format/emu/emulator4/Emu3DiskImage.java b/src/main/java/de/mossgrabers/convertwithmoss/format/emu/emulator4/Emu3DiskImage.java index 02626446..a56e8361 100644 --- a/src/main/java/de/mossgrabers/convertwithmoss/format/emu/emulator4/Emu3DiskImage.java +++ b/src/main/java/de/mossgrabers/convertwithmoss/format/emu/emulator4/Emu3DiskImage.java @@ -430,10 +430,14 @@ public static void writeImage (final File outputFile, final List file final long size = file.getContent ().length; final int numClusters = (int) ((size + clusterBytes - 1) / clusterBytes); final long lastClusterBytes = size - (numClusters - 1L) * clusterBytes; - final int lastBlockBytes = (int) (lastClusterBytes % BLOCK_SIZE); // A partially filled last block counts as a whole block, otherwise the firmware // does not read the tail of the file - final int lastClusterBlocks = (int) (lastClusterBytes / BLOCK_SIZE) + (lastBlockBytes > 0 ? 1 : 0); + final int lastClusterBlocks = (int) ((lastClusterBytes + BLOCK_SIZE - 1) / BLOCK_SIZE); + // The bytes used in the last block are derived from that block count and are therefore + // always 1..BLOCK_SIZE. A plain remainder would be 0 for a file whose last cluster is + // an exact multiple of the block size, which makes the firmware read one block too + // few and abort the load of that file with an end of file error + final int lastBlockBytes = (int) (lastClusterBytes - (lastClusterBlocks - 1L) * BLOCK_SIZE); Emulator4Constants.encodeName (block, offset, file.getName ()); block[offset + 17] = (byte) i; From 02ed6599e2193cebcdd9dc35308f4a3aeb617292 Mon Sep 17 00:00:00 2001 From: Douglas Carmichael Date: Sat, 25 Jul 2026 15:56:35 -0400 Subject: [PATCH 07/12] E-mu Emulator IV: name a read preset after its bank and drop the placeholder preset quietly The presets of the commercial EOS libraries are named after the articulation they provide ('Dark Tremolo', 'Long Release', 'Natural Range') while the instrument they play is only given by the name of their bank, so the converted multi-samples were not identifiable and presets of different banks overwrote each other. The bank name is now prepended, except when the preset name already starts with it, and each bank gets its own folder if the folder structure is created. Every bank of those libraries also ends with an unusable placeholder preset which references a sample that is not in the bank. It was already dropped but reported an error for each of its zones. Zones without a sample (index 0) are now skipped silently and a lost zone is only reported for a preset which does contribute other zones. The name fields of the disk filesystem are padded with zero bytes instead of spaces, which showed up as 'Greek Bazouki???' in the log and would have ended up in the created names. --- documentation/CHANGELOG.md | 2 +- documentation/README-FORMATS.md | 2 + .../emu/emulator4/Emulator4Constants.java | 5 +- .../emu/emulator4/Emulator4Detector.java | 99 ++++++++++++++++--- 4 files changed, 93 insertions(+), 15 deletions(-) diff --git a/documentation/CHANGELOG.md b/documentation/CHANGELOG.md index 973cd34c..d7aa387e 100644 --- a/documentation/CHANGELOG.md +++ b/documentation/CHANGELOG.md @@ -2,7 +2,7 @@ ## 19.2.0 -* New: Added support for the E-mu Emulator IV bank format (E4B). Written banks have not been tested on real hardware yet. +* New: Added support for the E-mu Emulator IV bank format (E4B). The name of the bank is prepended to the name of a read preset, since the presets of the EOS libraries are only named after their articulation ('Dark Tremolo') and not after the instrument they play. Written banks have not been tested on real hardware yet. * New: E-mu Emulator IV banks can also be read directly from CD-ROM and hard disk images of the EOS samplers (ISO, IMG, HDA), including via the ISO/IMG source format. * New: The E-mu Emulator IV destination can write the bank as a ready-to-use CD-ROM image for SCSI CD-ROM emulators (e.g. ZuluSCSI), which is the only way to load banks on units running EOS versions before 4.7. diff --git a/documentation/README-FORMATS.md b/documentation/README-FORMATS.md index 81ccc51f..88a742b6 100644 --- a/documentation/README-FORMATS.md +++ b/documentation/README-FORMATS.md @@ -283,6 +283,8 @@ Banks can be read from single *.e4b* files and also directly from CD-ROM and har When reading, every preset of a bank becomes one multi-sample and every voice becomes a group. Names, key and velocity ranges, root keys, tuning, volume, loops, the amplitude envelope with its velocity modulation, the filter (type, cutoff, resonance, key tracking and envelope with its depth) and the 16-bit sample data are read. The EOS effect filter types (phasers, flangers, vocal formants, EQ morphs) have no model equivalent, such voices are converted without a filter. +The presets of the commercial EOS libraries are named after the articulation or the variation they provide (*Dark Tremolo*, *Long Release*, ...) while the instrument they actually play is only given by the name of their bank. The name of the bank is therefore prepended to the name of the preset, except when the preset name already starts with it - the *Dark Tremolo* preset of the *Greek Bazouki* bank becomes *Greek Bazouki - Dark Tremolo* while *Greek Bazouki XS* is kept unchanged. This also keeps the presets of different banks apart, which would otherwise overwrite each other since a name like *Natural Range* is used in many banks. If the folder structure of the source is created, each bank additionally gets a folder of its own. + When writing, each multi-sample becomes one preset; a library collects all multi-samples into a single bank (up to 1000 presets and 1000 samples). Every zone is written as its own voice, which keeps all per-zone settings. Samples are stored as 16-bit mono PCM with their original sample rate (rates above 48kHz, the EOS maximum, are down-sampled); stereo samples are mixed down to mono. Identical samples mapped to multiple zones are stored only once. Since EOS only has a sample-level forward loop, alternating loops are written as forward loops and only the first loop of a zone is kept. Written banks validate against the reference parser of the mpc2emu project but have not been verified on real hardware yet. #### Destination Options diff --git a/src/main/java/de/mossgrabers/convertwithmoss/format/emu/emulator4/Emulator4Constants.java b/src/main/java/de/mossgrabers/convertwithmoss/format/emu/emulator4/Emulator4Constants.java index a20ea976..a6815217 100644 --- a/src/main/java/de/mossgrabers/convertwithmoss/format/emu/emulator4/Emulator4Constants.java +++ b/src/main/java/de/mossgrabers/convertwithmoss/format/emu/emulator4/Emulator4Constants.java @@ -433,7 +433,8 @@ public static boolean hasMagic (final byte [] data, final int offset, final byte /** - * Decode a 16 character space padded ASCII name field. + * Decode a 16 character space padded ASCII name field. The name fields of the disk filesystem + * are padded with zero bytes instead of spaces, therefore the name ends at the first one. * * @param data The data to read from * @param offset The offset of the name field @@ -445,6 +446,8 @@ public static String decodeName (final byte [] data, final int offset) for (int i = 0; i < NAME_LENGTH; i++) { final int c = data[offset + i] & 0xFF; + if (c == 0) + break; sb.append (c >= 0x20 && c < 0x7F ? (char) c : '?'); } return sb.toString ().trim (); diff --git a/src/main/java/de/mossgrabers/convertwithmoss/format/emu/emulator4/Emulator4Detector.java b/src/main/java/de/mossgrabers/convertwithmoss/format/emu/emulator4/Emulator4Detector.java index 9d69191a..d404c6d0 100644 --- a/src/main/java/de/mossgrabers/convertwithmoss/format/emu/emulator4/Emulator4Detector.java +++ b/src/main/java/de/mossgrabers/convertwithmoss/format/emu/emulator4/Emulator4Detector.java @@ -16,6 +16,7 @@ import java.util.Locale; import java.util.Map; import java.util.Set; +import java.util.TreeSet; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -103,7 +104,7 @@ public List readPresetFile (final File sourceFile) } final byte [] data = Files.readAllBytes (sourceFile.toPath ()); - return this.parseBank (sourceFile, sourceFile.getName (), data); + return this.parseBank (sourceFile, FileUtils.getNameWithoutType (sourceFile), data); } catch (final IOException ex) { @@ -162,15 +163,15 @@ private List parseImage (final File sourceFile) throws IOExc * Parse a bank and create one multi-sample source per preset. * * @param sourceFile The file which contains the bank (the bank file itself or a disk image) - * @param sourceName The name of the bank for logging + * @param bankName The name of the bank * @param data The content of the bank * @return The multi-sample sources */ - private List parseBank (final File sourceFile, final String sourceName, final byte [] data) + private List parseBank (final File sourceFile, final String bankName, final byte [] data) { if (data.length < 12 || !Emulator4Constants.hasMagic (data, 0, Emulator4Constants.FORM_MAGIC) || !Emulator4Constants.hasMagic (data, 8, Emulator4Constants.FORM_TYPE)) { - this.notifier.logError ("IDS_E4B_NOT_A_BANK", sourceName); + this.notifier.logError ("IDS_E4B_NOT_A_BANK", bankName); return Collections.emptyList (); } @@ -202,14 +203,14 @@ else if (Emulator4Constants.hasMagic (data, position, Emulator4Constants.SAMPLE_ final List results = new ArrayList<> (); for (final byte [] presetChunk: presetChunks) { - final IMultisampleSource multisampleSource = this.parsePreset (sourceFile, presetChunk, samplesByIndex); + final IMultisampleSource multisampleSource = this.parsePreset (sourceFile, bankName, presetChunk, samplesByIndex); if (multisampleSource != null) results.add (multisampleSource); } if (results.isEmpty ()) - this.notifier.logError ("IDS_E4B_NO_PRESETS", sourceName); + this.notifier.logError ("IDS_E4B_NO_PRESETS", bankName); else - this.notifier.log ("IDS_E4B_READING_BANK", sourceName, Integer.toString (results.size ()), Integer.toString (samplesByIndex.size ())); + this.notifier.log ("IDS_E4B_READING_BANK", bankName, Integer.toString (results.size ()), Integer.toString (samplesByIndex.size ())); return results; } @@ -299,11 +300,12 @@ private void parseSample (final byte [] data, final int offset, final int size, * applied to all zones of the voice. * * @param sourceFile The bank file + * @param bankName The name of the bank which contains the preset * @param body The content of the preset chunk * @param samplesByIndex The samples of the bank by their 1-based index * @return The multi-sample source or null if the preset contains no usable zones */ - private IMultisampleSource parsePreset (final File sourceFile, final byte [] body, final Map samplesByIndex) + private IMultisampleSource parsePreset (final File sourceFile, final String bankName, final byte [] body, final Map samplesByIndex) { if (body.length < Emulator4Constants.PRESET_HEADER_SIZE) return null; @@ -311,6 +313,7 @@ private IMultisampleSource parsePreset (final File sourceFile, final byte [] bod final String presetName = Emulator4Constants.decodeName (body, 2); final int numVoices = Emulator4Constants.getU16BE (body, 20); + final Set missingSampleIndices = new TreeSet<> (); final List groups = new ArrayList<> (); int offset = Emulator4Constants.PRESET_HEADER_SIZE; for (int voiceIndex = 0; voiceIndex < numVoices; voiceIndex++) @@ -332,7 +335,7 @@ private IMultisampleSource parsePreset (final File sourceFile, final byte [] bod } final IGroup group = new DefaultGroup ("Voice " + (voiceIndex + 1)); - this.parseVoice (body, offset, numZones, presetName, samplesByIndex, group); + parseVoice (body, offset, numZones, samplesByIndex, missingSampleIndices, group); if (!group.getSampleZones ().isEmpty ()) groups.add (group); @@ -340,9 +343,76 @@ private IMultisampleSource parsePreset (final File sourceFile, final byte [] bod // Only the last voice is followed by 2 trailing zero bytes but they do not matter here } + // Every bank of the commercial EOS libraries ends with an unusable placeholder preset + // which references a sample that is not in the bank. Such a preset is simply dropped; + // only a preset which does contribute zones is worth a warning about its lost ones if (groups.isEmpty ()) return null; - return this.createMultisampleSource (sourceFile, presetName.isBlank () ? FileUtils.getNameWithoutType (sourceFile) : presetName, groups); + for (final Integer missingSampleIndex: missingSampleIndices) + this.notifier.logError ("IDS_E4B_SAMPLE_MISSING", missingSampleIndex.toString (), presetName); + + final String name = createInstrumentName (bankName, presetName.isBlank () ? FileUtils.getNameWithoutType (sourceFile) : presetName); + final IMultisampleSource multisampleSource = this.createMultisampleSource (sourceFile, name, groups); + // Keep the presets of a bank together if the folder structure of the source is created + if (!bankName.isBlank ()) + multisampleSource.setSubPath (addBankFolder (multisampleSource.getSubPath (), bankName)); + return multisampleSource; + } + + + /** + * Create the name of the multi-sample source of a preset. The presets of the EOS libraries are + * named after the articulation or the variation they provide ('Dark Tremolo', 'Long Release', + * ...) while the instrument they actually play is only given by the name of their bank. The + * bank name is therefore prepended, except when the preset name already starts with it. + * + * @param bankName The name of the bank + * @param presetName The name of the preset + * @return The name to use for the multi-sample source + */ + private static String createInstrumentName (final String bankName, final String presetName) + { + if (bankName.isBlank ()) + return presetName; + return reduceToLettersAndDigits (presetName).startsWith (reduceToLettersAndDigits (bankName)) ? presetName : bankName + " - " + presetName; + } + + + /** + * Reduce a name to its lower case letters and digits, which allows to match names which only + * differ in their spaces and punctuation, e.g. 'GreekBazoukiMute' and 'Greek Bazouki'. + * + * @param text The text to reduce + * @return The reduced text + */ + private static String reduceToLettersAndDigits (final String text) + { + final StringBuilder sb = new StringBuilder (text.length ()); + for (int i = 0; i < text.length (); i++) + { + final char c = text.charAt (i); + if (Character.isLetterOrDigit (c)) + sb.append (Character.toLowerCase (c)); + } + return sb.toString (); + } + + + /** + * Add the bank as the innermost sub-folder of the path parts. The first entry of the array is + * the name of the multi-sample and not part of the path. + * + * @param parts The path parts + * @param bankName The name of the bank + * @return The extended path parts + */ + private static String [] addBankFolder (final String [] parts, final String bankName) + { + final String [] result = new String [parts.length + 1]; + result[0] = parts[0]; + result[1] = bankName; + System.arraycopy (parts, 1, result, 2, parts.length - 1); + return result; } @@ -352,11 +422,11 @@ private IMultisampleSource parsePreset (final File sourceFile, final byte [] bod * @param body The content of the preset chunk * @param offset The offset of the voice block * @param numZones The number of zone entries of the voice - * @param presetName The name of the preset, for error messages * @param samplesByIndex The samples of the bank by their 1-based index + * @param missingSampleIndices Where to collect the indices of referenced but absent samples * @param group Where to add the created zones */ - private void parseVoice (final byte [] body, final int offset, final int numZones, final String presetName, final Map samplesByIndex, final IGroup group) + private static void parseVoice (final byte [] body, final int offset, final int numZones, final Map samplesByIndex, final Set missingSampleIndices, final IGroup group) { // Per-voice tuning: key transpose and coarse tune in semitones, fine tune in 1/64 // semitone units. All three simply offset the playback pitch of the zones @@ -403,11 +473,14 @@ else if (destination == 0x38 && source == 0x08) for (int zoneIndex = 0; zoneIndex < numZones; zoneIndex++) { final int entryOffset = offset + Emulator4Constants.VOICE_SIZE + zoneIndex * Emulator4Constants.ZONE_ENTRY_SIZE; + // The sample indices are 1-based, index 0 means that the zone has no sample assigned final int sampleIndex = Emulator4Constants.getU16BE (body, entryOffset + 10); + if (sampleIndex == 0) + continue; final Sample sample = samplesByIndex.get (Integer.valueOf (sampleIndex)); if (sample == null) { - this.notifier.logError ("IDS_E4B_SAMPLE_MISSING", Integer.toString (sampleIndex), presetName); + missingSampleIndices.add (Integer.valueOf (sampleIndex)); continue; } From 7ce000f65be543a440a62052efad9becd9698937 Mon Sep 17 00:00:00 2001 From: Douglas Carmichael Date: Sat, 25 Jul 2026 16:15:41 -0400 Subject: [PATCH 08/12] E-mu Emulator IV: carry the bank as the description and pass the per-zone offsets through Formats which have a field of their own for the bank take it from the description, e.g. the Waldorf Quantum/Iridium shows it next to the preset name. Prepending the bank to the preset name is therefore switchable now, since a device with little room for a name is better served by the short preset name plus the bank field. The zone entries carry three offsets on top of the settings of their voice which were dropped so far. They are not decoded by mpc2emu and were recovered from the E-mu Producer Series CD-ROMs; see documentation/design/E4B_FORMAT.md for the evidence: * fine tune at 12:14, a signed big-endian value in 1/64 semitones, which corrects the recorded pitch of the individual sample (measuring the samples against the root key of their zone drops the average pitch error of a bank from 12.3 to 7.7 cents when applied) * volume at 15, signed dB, used by the mixed presets to balance their zones * panning at 16, -64 to +63, used by the stereo spreads ('Wide', 'Percussion Section'); this is also written now, it has no voice level counterpart The pitch offset of a sample is read as well, as far as it detunes the sample beyond compensating its sample rate - which the sample data itself already does here. A larger deviation means the bank does not maintain the field and is ignored rather than transposing the sample. --- documentation/CHANGELOG.md | 2 +- documentation/README-FORMATS.md | 12 +- .../SupportedFeaturesSampleFormats.ods | Bin 43408 -> 44890 bytes documentation/design/E4B_FORMAT.md | 21 +++ .../emu/emulator4/Emulator4Creator.java | 2 + .../emu/emulator4/Emulator4Detector.java | 41 ++++-- .../emu/emulator4/Emulator4DetectorUI.java | 131 ++++++++++++++++++ src/main/resources/Strings.properties | 2 + 8 files changed, 199 insertions(+), 12 deletions(-) create mode 100644 src/main/java/de/mossgrabers/convertwithmoss/format/emu/emulator4/Emulator4DetectorUI.java diff --git a/documentation/CHANGELOG.md b/documentation/CHANGELOG.md index d7aa387e..978dc83b 100644 --- a/documentation/CHANGELOG.md +++ b/documentation/CHANGELOG.md @@ -2,7 +2,7 @@ ## 19.2.0 -* New: Added support for the E-mu Emulator IV bank format (E4B). The name of the bank is prepended to the name of a read preset, since the presets of the EOS libraries are only named after their articulation ('Dark Tremolo') and not after the instrument they play. Written banks have not been tested on real hardware yet. +* New: Added support for the E-mu Emulator IV bank format (E4B). The name of the bank is passed on as the description and - switchable - prepended to the name of a read preset, since the presets of the EOS libraries are only named after their articulation ('Dark Tremolo') and not after the instrument they play. Written banks have not been tested on real hardware yet. * New: E-mu Emulator IV banks can also be read directly from CD-ROM and hard disk images of the EOS samplers (ISO, IMG, HDA), including via the ISO/IMG source format. * New: The E-mu Emulator IV destination can write the bank as a ready-to-use CD-ROM image for SCSI CD-ROM emulators (e.g. ZuluSCSI), which is the only way to load banks on units running EOS versions before 4.7. diff --git a/documentation/README-FORMATS.md b/documentation/README-FORMATS.md index 88a742b6..d03dd347 100644 --- a/documentation/README-FORMATS.md +++ b/documentation/README-FORMATS.md @@ -281,11 +281,17 @@ The E-mu Emulator IV series (Emulator 4, E4X, E4XT, E4K, e-Synth, e-6400 and the Banks can be read from single *.e4b* files and also directly from CD-ROM and hard disk images of the EOS samplers (*.iso*, *.img*, *.hda* - e.g. images for SCSI emulators like the ZuluSCSI or dumps of commercial E-mu CD-ROMs), which use the proprietary E-mu disk filesystem. All banks of an image are read; files of the older EIII samplers, which use the same filesystem for their banks, are skipped. -When reading, every preset of a bank becomes one multi-sample and every voice becomes a group. Names, key and velocity ranges, root keys, tuning, volume, loops, the amplitude envelope with its velocity modulation, the filter (type, cutoff, resonance, key tracking and envelope with its depth) and the 16-bit sample data are read. The EOS effect filter types (phasers, flangers, vocal formants, EQ morphs) have no model equivalent, such voices are converted without a filter. +When reading, every preset of a bank becomes one multi-sample and every voice becomes a group. Names, key and velocity ranges, root keys, tuning, volume, panning, loops, the amplitude envelope with its velocity modulation, the filter (type, cutoff, resonance, key tracking and envelope with its depth) and the 16-bit sample data are read. The per-zone offsets for fine tuning, volume and panning are applied on top of the settings of their voice, and the pitch offset of a sample is applied as a fine tuning as far as it detunes the sample beyond compensating its sample rate. The EOS effect filter types (phasers, flangers, vocal formants, EQ morphs) have no model equivalent, such voices are converted without a filter. -The presets of the commercial EOS libraries are named after the articulation or the variation they provide (*Dark Tremolo*, *Long Release*, ...) while the instrument they actually play is only given by the name of their bank. The name of the bank is therefore prepended to the name of the preset, except when the preset name already starts with it - the *Dark Tremolo* preset of the *Greek Bazouki* bank becomes *Greek Bazouki - Dark Tremolo* while *Greek Bazouki XS* is kept unchanged. This also keeps the presets of different banks apart, which would otherwise overwrite each other since a name like *Natural Range* is used in many banks. If the folder structure of the source is created, each bank additionally gets a folder of its own. +The name of the bank is passed on as the description of the multi-sample, which the formats that have a field of their own for the bank take it from - the Waldorf Quantum/Iridium for example shows it next to the preset name. -When writing, each multi-sample becomes one preset; a library collects all multi-samples into a single bank (up to 1000 presets and 1000 samples). Every zone is written as its own voice, which keeps all per-zone settings. Samples are stored as 16-bit mono PCM with their original sample rate (rates above 48kHz, the EOS maximum, are down-sampled); stereo samples are mixed down to mono. Identical samples mapped to multiple zones are stored only once. Since EOS only has a sample-level forward loop, alternating loops are written as forward loops and only the first loop of a zone is kept. Written banks validate against the reference parser of the mpc2emu project but have not been verified on real hardware yet. +The presets of the commercial EOS libraries are named after the articulation or the variation they provide (*Dark Tremolo*, *Long Release*, ...) while the instrument they actually play is only given by the name of their bank. The name of the bank is therefore prepended to the name of the preset as well, except when the preset name already starts with it - the *Dark Tremolo* preset of the *Greek Bazouki* bank becomes *Greek Bazouki - Dark Tremolo* while *Greek Bazouki XS* is kept unchanged. This also keeps the presets of different banks apart, which would otherwise overwrite each other since a name like *Natural Range* is used in many banks. If the folder structure of the source is created, each bank additionally gets a folder of its own. + +#### Source Options + +* Prepend the bank name to the preset name: Enabled by default, see above. Disable it to keep the preset names unchanged, which is useful for devices with little room for a name - the bank is then only carried in the description. + +When writing, each multi-sample becomes one preset; a library collects all multi-samples into a single bank (up to 1000 presets and 1000 samples). Every zone is written as its own voice, which keeps all per-zone settings; the panning, which only exists per zone, is written into the zone entry. Samples are stored as 16-bit mono PCM with their original sample rate (rates above 48kHz, the EOS maximum, are down-sampled); stereo samples are mixed down to mono. Identical samples mapped to multiple zones are stored only once. Since EOS only has a sample-level forward loop, alternating loops are written as forward loops and only the first loop of a zone is kept. Written banks validate against the reference parser of the mpc2emu project but have not been verified on real hardware yet. #### Destination Options diff --git a/documentation/SupportedFeaturesSampleFormats.ods b/documentation/SupportedFeaturesSampleFormats.ods index 69f462f27d8534b6a63c0a74c3f92595b5c92c72..9a484df36848997bc4e80cb99eb28a0b2148b8b7 100644 GIT binary patch delta 29680 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 NN-00IRs1>g{{Z$BkN^Mx delta 28142 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 diff --git a/documentation/design/E4B_FORMAT.md b/documentation/design/E4B_FORMAT.md index 943439bf..96ee8c49 100644 --- a/documentation/design/E4B_FORMAT.md +++ b/documentation/design/E4B_FORMAT.md @@ -159,7 +159,28 @@ by the hardware, so the creator always writes the factory default set. | 6 | low velocity | | 9 | high velocity | | 10:12 | sample index (u16, 1-based) | +| 12:14 | fine tune (signed **big-endian** u16, 1/64 semitone units) | | 14 | root key | +| 15 | volume (signed dB, like the voice volume) | +| 16 | panning (signed, -64 = full left ... 0 = centre ... +63 = full right) | + +The three offset fields at 12, 15 and 16 apply on top of the settings of the +voice and are not decoded by mpc2emu (which writes zeros for them). They were +recovered from the E-mu Producer Series CD-ROMs (76057 zones): + +* The two bytes at 12:14 only ever hold 0x00 or 0xFF in the high byte, exactly + matching the sign of the low byte, which identifies them as one signed + big-endian value (observed range -59..+48). Measuring the recorded pitch of + the samples against the root key of their zone shows the field to be a + correction of that deviation: applying it as 1/64 semitones reduces the + average pitch error of a bank from 12.3 to 7.7 cents (as cents it would only + reach 8.7), which also matches the 1/64 semitone resolution the rest of the + format uses for tuning. +* Byte 15 has the small signed range -17..+10 of a level trim and is used by the + mixed instrument presets ('Percussion Section') which balance their zones. +* Byte 16 spans the full signed -64..+63 and its extremes dominate; the presets + which use it are the stereo spreads ('Wide', 'Percussion Section', + 'Congas/Bongos') while the plain single instrument presets leave it at 0. ## Sample (E3S1) diff --git a/src/main/java/de/mossgrabers/convertwithmoss/format/emu/emulator4/Emulator4Creator.java b/src/main/java/de/mossgrabers/convertwithmoss/format/emu/emulator4/Emulator4Creator.java index 9d06108c..434debd8 100644 --- a/src/main/java/de/mossgrabers/convertwithmoss/format/emu/emulator4/Emulator4Creator.java +++ b/src/main/java/de/mossgrabers/convertwithmoss/format/emu/emulator4/Emulator4Creator.java @@ -371,6 +371,8 @@ private int addSample (final ISampleZone zone, final List samples, final voice[entryOffset + 9] = (byte) velocityHigh; Emulator4Constants.putU16BE (voice, entryOffset + 10, sampleIndex); voice[entryOffset + 14] = (byte) Math.clamp (zone.getKeyRoot () < 0 ? keyLow : zone.getKeyRoot (), 0, 127); + // The panning has no voice level counterpart, it only exists per zone + voice[entryOffset + 16] = (byte) Math.clamp (Math.round (zone.getPanning () * 64.0), -64, 63); return voice; } diff --git a/src/main/java/de/mossgrabers/convertwithmoss/format/emu/emulator4/Emulator4Detector.java b/src/main/java/de/mossgrabers/convertwithmoss/format/emu/emulator4/Emulator4Detector.java index d404c6d0..922cfddf 100644 --- a/src/main/java/de/mossgrabers/convertwithmoss/format/emu/emulator4/Emulator4Detector.java +++ b/src/main/java/de/mossgrabers/convertwithmoss/format/emu/emulator4/Emulator4Detector.java @@ -65,6 +65,7 @@ private static class Sample InMemorySampleData sampleData; int numFrames; int rootKey; + double tuning; boolean hasLoop; int loopStart; int loopEnd; @@ -78,7 +79,7 @@ private static class Sample */ public Emulator4Detector (final INotifier notifier) { - super ("E-mu Emulator IV", "E4B", notifier, new MetadataSettingsUI ("E4B"), ".e4b", ".iso", ".img", ".hda"); + super ("E-mu Emulator IV", "E4B", notifier, new Emulator4DetectorUI ("E4B"), ".e4b", ".iso", ".img", ".hda"); } @@ -239,6 +240,7 @@ private void parseSample (final byte [] data, final int offset, final int size, final long loopStartOffset = Emulator4Constants.getU32LE (data, offset + 38); final long loopEndOffset = Emulator4Constants.getU32LE (data, offset + 46); final int sampleRate = (int) Emulator4Constants.getU32LE (data, offset + 54); + final int pitchOffset = (short) Emulator4Constants.getU16LE (data, offset + 58); final int options = Emulator4Constants.getU16LE (data, offset + 60); final int pcmLength = (size - Emulator4Constants.SAMPLE_HEADER_SIZE) / 2 * 2; @@ -255,6 +257,14 @@ private void parseSample (final byte [] data, final int offset, final int size, sample.sampleData = new InMemorySampleData (new DefaultAudioMetadata (1, sampleRate, 16, numFrames), pcm); sample.numFrames = numFrames; + // The sampler always plays a sample back at 44.1 kHz and compensates a different sample + // rate with the pitch offset, which the sample data itself already does here. Whatever the + // offset holds beyond that compensation is a fine tuning of the sample. A larger deviation + // means that the bank was written without maintaining the field at all (which makes the + // sample play transposed on the hardware) and is ignored instead of transposing the sample + final double pitchDeviation = (pitchOffset - Emulator4Constants.calculatePitchOffset (sampleRate)) / 64.0; + sample.tuning = Math.abs (pitchDeviation) <= 1.0 ? pitchDeviation : 0; + // The root note is conventionally appended to the name, e.g. 'Piano_C3' for MIDI note 60. // The zone entries carry the authoritative root key, this one is only the fallback String baseName = displayName; @@ -351,11 +361,19 @@ private IMultisampleSource parsePreset (final File sourceFile, final String bank for (final Integer missingSampleIndex: missingSampleIndices) this.notifier.logError ("IDS_E4B_SAMPLE_MISSING", missingSampleIndex.toString (), presetName); - final String name = createInstrumentName (bankName, presetName.isBlank () ? FileUtils.getNameWithoutType (sourceFile) : presetName); - final IMultisampleSource multisampleSource = this.createMultisampleSource (sourceFile, name, groups); - // Keep the presets of a bank together if the folder structure of the source is created + // The generic ISO detector runs this detector with its own settings, which have no option + // of this format; prepending the bank name is the default there as well + final boolean prependBankName = !(this.settingsConfiguration instanceof final Emulator4DetectorUI settings) || settings.prependBankName (); + final String name = presetName.isBlank () ? FileUtils.getNameWithoutType (sourceFile) : presetName; + final IMultisampleSource multisampleSource = this.createMultisampleSource (sourceFile, createInstrumentName (bankName, name, prependBankName), groups); + // Formats which have a field of their own for the bank (e.g. the Waldorf Quantum/Iridium, + // which shows it next to the preset name) take it from the description if (!bankName.isBlank ()) + { + multisampleSource.getMetadata ().setDescription (bankName); + // Keep the presets of a bank together if the folder structure of the source is created multisampleSource.setSubPath (addBankFolder (multisampleSource.getSubPath (), bankName)); + } return multisampleSource; } @@ -368,11 +386,12 @@ private IMultisampleSource parsePreset (final File sourceFile, final String bank * * @param bankName The name of the bank * @param presetName The name of the preset + * @param prependBankName True to prepend the name of the bank * @return The name to use for the multi-sample source */ - private static String createInstrumentName (final String bankName, final String presetName) + private static String createInstrumentName (final String bankName, final String presetName, final boolean prependBankName) { - if (bankName.isBlank ()) + if (!prependBankName || bankName.isBlank ()) return presetName; return reduceToLettersAndDigits (presetName).startsWith (reduceToLettersAndDigits (bankName)) ? presetName : bankName + " - " + presetName; } @@ -501,8 +520,14 @@ else if (destination == 0x38 && source == 0x08) zone.setKeyRoot (rootKey > 0 && rootKey < 128 ? rootKey : sample.rootKey); zone.setStart (0); zone.setStop (sample.numFrames); - zone.setTuning (tuning); - zone.setGain (volume); + + // The per-zone offsets on top of the settings of the voice: the fine tuning corrects + // the recorded pitch of the individual sample, the volume balances the zones against + // each other and the panning places them in the stereo field + final int zoneFineTune = (short) Emulator4Constants.getU16BE (body, entryOffset + 12); + zone.setTuning (tuning + zoneFineTune / 64.0 + sample.tuning); + zone.setGain (volume + body[entryOffset + 15]); + zone.setPanning (Math.clamp (body[entryOffset + 16] / 64.0, -1, 1)); if (isFixedPitch) zone.setKeyTracking (0); diff --git a/src/main/java/de/mossgrabers/convertwithmoss/format/emu/emulator4/Emulator4DetectorUI.java b/src/main/java/de/mossgrabers/convertwithmoss/format/emu/emulator4/Emulator4DetectorUI.java new file mode 100644 index 00000000..b55b2800 --- /dev/null +++ b/src/main/java/de/mossgrabers/convertwithmoss/format/emu/emulator4/Emulator4DetectorUI.java @@ -0,0 +1,131 @@ +// 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.emu.emulator4; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +import de.mossgrabers.convertwithmoss.core.INotifier; +import de.mossgrabers.convertwithmoss.core.settings.MetadataSettingsUI; +import de.mossgrabers.tools.ui.BasicConfig; +import de.mossgrabers.tools.ui.panel.BoxPanel; +import javafx.geometry.Orientation; +import javafx.scene.control.CheckBox; +import javafx.scene.layout.Pane; + + +/** + * Settings of the E-mu Emulator IV detector. + * + * @author Jürgen Moßgraber + */ +public class Emulator4DetectorUI extends MetadataSettingsUI +{ + private static final String PREPEND_BANK_NAME = "PrependBankName"; + + private CheckBox prependBankNameCheckBox; + private boolean prependBankName; + + + /** + * Constructor. + * + * @param prefix The prefix to use for the properties tags + */ + public Emulator4DetectorUI (final String prefix) + { + super (prefix); + } + + + /** {@inheritDoc} */ + @Override + public Pane getEditPane () + { + final BoxPanel panel = new BoxPanel (Orientation.VERTICAL); + + // ----------------------------------------------------------- + // Options + + panel.createSeparator ("@IDS_E4B_SOURCE_OPTIONS"); + + this.prependBankNameCheckBox = panel.createCheckBox ("@IDS_E4B_PREPEND_BANK_NAME"); + + // ----------------------------------------------------------- + // Metadata + + this.addTo (panel); + this.getSeparator ().getStyleClass ().add ("titled-separator-pane"); + return panel.getPane (); + } + + + /** {@inheritDoc} */ + @Override + public void saveSettings (final BasicConfig config) + { + super.saveSettings (config); + + config.setBoolean (this.prefix + PREPEND_BANK_NAME, this.prependBankNameCheckBox.isSelected ()); + } + + + /** {@inheritDoc} */ + @Override + public void loadSettings (final BasicConfig config) + { + super.loadSettings (config); + + this.prependBankNameCheckBox.setSelected (config.getBoolean (this.prefix + PREPEND_BANK_NAME, true)); + } + + + /** {@inheritDoc} */ + @Override + public boolean checkSettingsUI (final INotifier notifier) + { + if (!super.checkSettingsUI (notifier)) + return false; + + this.prependBankName = this.prependBankNameCheckBox.isSelected (); + return true; + } + + + /** {@inheritDoc} */ + @Override + public boolean checkSettingsCLI (final INotifier notifier, final Map parameters) + { + if (!super.checkSettingsCLI (notifier, parameters)) + return false; + + final String value = parameters.remove (this.prefix + PREPEND_BANK_NAME); + this.prependBankName = value == null || "1".equals (value); + return true; + } + + + /** {@inheritDoc} */ + @Override + public String [] getCLIParameterNames () + { + final List parameterNames = new ArrayList<> (Arrays.asList (super.getCLIParameterNames ())); + parameterNames.add (this.prefix + PREPEND_BANK_NAME); + return parameterNames.toArray (new String [parameterNames.size ()]); + } + + + /** + * Should the name of the bank be prepended to the name of a preset? + * + * @return True to prepend the bank name + */ + public boolean prependBankName () + { + return this.prependBankName; + } +} diff --git a/src/main/resources/Strings.properties b/src/main/resources/Strings.properties index dd6a0b58..7a0c23b7 100644 --- a/src/main/resources/Strings.properties +++ b/src/main/resources/Strings.properties @@ -394,6 +394,8 @@ IDS_E4B_MIXED_TO_MONO=%1 stereo sample(s) of '%2' were mixed down to mono.\n IDS_E4B_NO_ZONES=No convertible sample zones found in '%1'. Skipped.\n IDS_E4B_TOO_MANY_SAMPLES=All 1000 sample slots of the bank are in use. Sample '%1' skipped.\n IDS_E4B_TOO_MANY_PRESETS=All 1000 preset slots of the bank are in use. '%1' skipped.\n +IDS_E4B_SOURCE_OPTIONS=Options +IDS_E4B_PREPEND_BANK_NAME=Prepend the bank name to the preset name IDS_E4B_OUTPUT_FORMAT=Output Format IDS_E4B_WRITE_CD_IMAGE=Create CD-ROM image (.iso) for SCSI CD-ROM emulators From c72bf385e81e373c12c4de5898d76ca9146ae36f Mon Sep 17 00:00:00 2001 From: Douglas Carmichael Date: Sat, 25 Jul 2026 17:18:43 -0400 Subject: [PATCH 09/12] E-mu Emulator IV: do not repeat the bank in the 16 character preset names The detector puts the bank of a source in front of its name, so that a preset which is only named after its articulation stays identifiable and unique. A written bank does not need its preset names to repeat that, and could not: they hold 16 characters, which the bank alone fills. A round-tripped bank collapsed its 20 distinct presets into 6 names ('Penny Whistle -'), now they are 20 again. The file name of the bank keeps the full name. --- documentation/README-FORMATS.md | 4 ++- .../emu/emulator4/Emulator4Creator.java | 27 +++++++++++++++++-- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/documentation/README-FORMATS.md b/documentation/README-FORMATS.md index d03dd347..58f9a0b3 100644 --- a/documentation/README-FORMATS.md +++ b/documentation/README-FORMATS.md @@ -287,9 +287,11 @@ The name of the bank is passed on as the description of the multi-sample, which The presets of the commercial EOS libraries are named after the articulation or the variation they provide (*Dark Tremolo*, *Long Release*, ...) while the instrument they actually play is only given by the name of their bank. The name of the bank is therefore prepended to the name of the preset as well, except when the preset name already starts with it - the *Dark Tremolo* preset of the *Greek Bazouki* bank becomes *Greek Bazouki - Dark Tremolo* while *Greek Bazouki XS* is kept unchanged. This also keeps the presets of different banks apart, which would otherwise overwrite each other since a name like *Natural Range* is used in many banks. If the folder structure of the source is created, each bank additionally gets a folder of its own. +A written bank does not repeat this in its preset names, since the file itself is that bank and its preset names hold only 16 characters - which the bank alone would fill. The file name of the bank keeps the full name. + #### Source Options -* Prepend the bank name to the preset name: Enabled by default, see above. Disable it to keep the preset names unchanged, which is useful for devices with little room for a name - the bank is then only carried in the description. +* Prepend the bank name to the preset name: Enabled by default, see above. Disable it to keep the preset names exactly as they are in the bank - the bank is then only carried in the description, and the written file names are the bare preset names. When writing, each multi-sample becomes one preset; a library collects all multi-samples into a single bank (up to 1000 presets and 1000 samples). Every zone is written as its own voice, which keeps all per-zone settings; the panning, which only exists per zone, is written into the zone entry. Samples are stored as 16-bit mono PCM with their original sample rate (rates above 48kHz, the EOS maximum, are down-sampled); stereo samples are mixed down to mono. Identical samples mapped to multiple zones are stored only once. Since EOS only has a sample-level forward loop, alternating loops are written as forward loops and only the first loop of a zone is kept. Written banks validate against the reference parser of the mpc2emu project but have not been verified on real hardware yet. diff --git a/src/main/java/de/mossgrabers/convertwithmoss/format/emu/emulator4/Emulator4Creator.java b/src/main/java/de/mossgrabers/convertwithmoss/format/emu/emulator4/Emulator4Creator.java index 434debd8..8a9445f1 100644 --- a/src/main/java/de/mossgrabers/convertwithmoss/format/emu/emulator4/Emulator4Creator.java +++ b/src/main/java/de/mossgrabers/convertwithmoss/format/emu/emulator4/Emulator4Creator.java @@ -163,8 +163,11 @@ private void writeBank (final File destinationFolder, final List prefix.length () ? name.substring (prefix.length ()) : name; + } + + /** * Convert the sample of a zone to 16-bit mono PCM and add it to the bank samples, re-using an * already added sample with identical content. From eb08fa37717b229c7a8c2f3b7e2751c0d516d48d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Jul 2026 01:35:15 +0000 Subject: [PATCH 10/12] E-mu Emulator IV: the per sample zone parameters are absolute values, not offsets The fine tuning, the volume and the panning at the offsets 12, 15 and 16 of a zone entry were read as offsets on top of the voice parameters 36 and 54. Jan Lentfer confirmed on E4XT hardware that the zone entry and the voice hold the same three parameters as alternatives which are never combined: a voice with a single zone keeps them in its voice parameters and leaves the zone entry at zero, while a voice with several zones keeps the absolute value of each zone in its own zone entry and leaves the voice parameters at zero. The reader now picks the source of the three parameters by the number of zones of the voice and additionally reads the voice panning at 55, which was not decoded before. The writer creates one single zone voice per zone, so it now writes the panning to the voice parameter 55 instead of the zone entry, next to the fine tuning and the volume it already wrote there. --- documentation/design/E4B_FORMAT.md | 50 ++++++++++++++----- .../emu/emulator4/Emulator4Creator.java | 7 ++- .../emu/emulator4/Emulator4Detector.java | 32 ++++++++---- 3 files changed, 63 insertions(+), 26 deletions(-) diff --git a/documentation/design/E4B_FORMAT.md b/documentation/design/E4B_FORMAT.md index 96ee8c49..5e6ffe46 100644 --- a/documentation/design/E4B_FORMAT.md +++ b/documentation/design/E4B_FORMAT.md @@ -103,11 +103,12 @@ Used voice parameters (all single bytes unless noted): | 25 | 0x7F constant | | 34 | key transpose (signed semitones) | | 35 | coarse tune (signed semitones) | -| 36 | fine tune (signed, 1/64 semitone units) | +| 36 | fine tune (signed, 1/64 semitone units) - only used by a voice with a single zone, see below | | 38 | 1 = non-transpose (fixed pitch); such a voice needs a populated cord table to be valid | | 42 | chorus amount (0-127) | | 51 | 0x80 constant | -| 54 | voice volume (signed dB) | +| 54 | voice volume (signed dB) - only used by a voice with a single zone, see below | +| 55 | voice panning (signed, -64 = full left ... 0 = centre ... +63 = full right) - only used by a voice with a single zone, see below | | 58 | filter type (see below) | | 60 | filter cutoff: 0 = ~57 Hz ... 255 = 20 kHz, exponential | | 61 | filter resonance 0-127 | @@ -159,14 +160,30 @@ by the hardware, so the creator always writes the factory default set. | 6 | low velocity | | 9 | high velocity | | 10:12 | sample index (u16, 1-based) | -| 12:14 | fine tune (signed **big-endian** u16, 1/64 semitone units) | +| 12:14 | fine tune (signed **big-endian** u16, 1/64 semitone units) - only used by a voice with several zones, see below | | 14 | root key | -| 15 | volume (signed dB, like the voice volume) | -| 16 | panning (signed, -64 = full left ... 0 = centre ... +63 = full right) | +| 15 | volume (signed dB, like the voice volume) - only used by a voice with several zones, see below | +| 16 | panning (signed, -64 = full left ... 0 = centre ... +63 = full right) - only used by a voice with several zones, see below | -The three offset fields at 12, 15 and 16 apply on top of the settings of the -voice and are not decoded by mpc2emu (which writes zeros for them). They were -recovered from the E-mu Producer Series CD-ROMs (76057 zones): +The fields at 12, 15 and 16 are the fine tuning, the volume and the panning of +the zone. They are **not** offsets on top of the voice fields 36, 54 and 55 but +the alternative to them, and the two are never combined: + +* a voice with a **single** zone stores the three values in its voice + parameters 36/54/55 and leaves the zone entry fields at zero, +* a voice with **several** zones stores the absolute value of each zone in its + own zone entry and leaves the voice parameters 36/54/55 at zero. + +Editing the aggregated volume, panning or fine tuning of a multi-zone voice on +the front panel only changes the voice parameters and never the zone entries, +so both really are independent. + +The three fields were recovered from the E-mu Producer Series CD-ROMs (76057 +zones) and then confirmed on E4XT hardware by the mpc2emu project (see +git-moss/ConvertWithMoss#220 and the `E4B_FORMAT.md` of mpc2emu), which also +disproved the offset interpretation they were first read with: a bank written +with the zone entries holding offsets shows the raw offsets on the front panel +instead of the intended sums. * The two bytes at 12:14 only ever hold 0x00 or 0xFF in the high byte, exactly matching the sign of the low byte, which identifies them as one signed @@ -265,13 +282,18 @@ it. ## Mapping decisions of the converter * **Reading:** every preset becomes one multi-sample source, every voice one - group. The voice tuning (transpose + coarse + fine) and volume are applied - to its zones; a non-transpose voice sets key tracking 0. A fully open + group. The voice transpose and coarse tune are applied to all its zones, + while the fine tuning, the volume and the panning are taken from the voice + for a single-zone voice and from the zone entry for a multi-zone voice (see + above); a non-transpose voice sets key tracking 0. A fully open 4-pole low-pass without resonance, envelope depth and key tracking is the EOS bypass state and creates no filter. * **Writing:** every zone becomes its own voice with a single zone entry, - which keeps the per-zone tuning, volume, filter and envelopes (hardware - banks typically map many zones into one voice; both layouts are valid). + which keeps the per-zone tuning, volume, panning, filter and envelopes + (hardware banks typically map many zones into one voice; both layouts are + valid). Since every written voice has exactly one zone, the fine tuning, the + volume and the panning go into the voice parameters 36/54/55 and the + matching zone entry fields stay zero. Stereo samples are mixed down to mono (the stereo variant of the sample struct is not covered by the mpc2emu reverse-engineering); sample rates above 48 kHz, the EOS maximum, are down-sampled. Samples are @@ -285,4 +307,6 @@ it. Read and write are validated against the mpc2emu reference parser and against hardware-created third-party banks (Ian Wilson's free EOS banks: 198 presets, 1980 zones, 317 samples - all sample PCM byte-identical, all zones resolved). -Written banks have **not** been loaded on real hardware yet. +The voice and zone entry fine tuning, volume and panning were additionally +confirmed on an E4XT by the mpc2emu project. Written banks have **not** been +loaded on real hardware yet. diff --git a/src/main/java/de/mossgrabers/convertwithmoss/format/emu/emulator4/Emulator4Creator.java b/src/main/java/de/mossgrabers/convertwithmoss/format/emu/emulator4/Emulator4Creator.java index 8a9445f1..b04ab2da 100644 --- a/src/main/java/de/mossgrabers/convertwithmoss/format/emu/emulator4/Emulator4Creator.java +++ b/src/main/java/de/mossgrabers/convertwithmoss/format/emu/emulator4/Emulator4Creator.java @@ -351,7 +351,12 @@ private int addSample (final ISampleZone zone, final List samples, final voice[36] = (byte) Math.clamp (Math.round ((tuning - semitones) * 64.0), -64, 63); voice[38] = (byte) (zone.getKeyTracking () == 0 ? 1 : 0); voice[51] = (byte) 0x80; + // The fine tuning above, the volume and the panning belong to the voice as long as it has + // only a single zone, which is always the case here; the matching fields of the zone entry + // are only used by a voice with several zones and must stay zero (E4XT hardware confirmed, + // see git-moss/ConvertWithMoss#220) voice[54] = (byte) Math.clamp (Math.round (zone.getGain ()), -96, 24); + voice[55] = (byte) Math.clamp (Math.round (zone.getPanning () * 64.0), -64, 63); // The filter setting; without a filter write the EOS bypass state (4-pole low-pass, // fully open, no resonance) @@ -394,8 +399,6 @@ private int addSample (final ISampleZone zone, final List samples, final voice[entryOffset + 9] = (byte) velocityHigh; Emulator4Constants.putU16BE (voice, entryOffset + 10, sampleIndex); voice[entryOffset + 14] = (byte) Math.clamp (zone.getKeyRoot () < 0 ? keyLow : zone.getKeyRoot (), 0, 127); - // The panning has no voice level counterpart, it only exists per zone - voice[entryOffset + 16] = (byte) Math.clamp (Math.round (zone.getPanning () * 64.0), -64, 63); return voice; } diff --git a/src/main/java/de/mossgrabers/convertwithmoss/format/emu/emulator4/Emulator4Detector.java b/src/main/java/de/mossgrabers/convertwithmoss/format/emu/emulator4/Emulator4Detector.java index 922cfddf..1bbf953a 100644 --- a/src/main/java/de/mossgrabers/convertwithmoss/format/emu/emulator4/Emulator4Detector.java +++ b/src/main/java/de/mossgrabers/convertwithmoss/format/emu/emulator4/Emulator4Detector.java @@ -447,11 +447,20 @@ private static String reduceToLettersAndDigits (final String text) */ private static void parseVoice (final byte [] body, final int offset, final int numZones, final Map samplesByIndex, final Set missingSampleIndices, final IGroup group) { - // Per-voice tuning: key transpose and coarse tune in semitones, fine tune in 1/64 - // semitone units. All three simply offset the playback pitch of the zones - final double tuning = body[offset + 34] + body[offset + 35] + body[offset + 36] / 64.0; + // Per-voice tuning: key transpose and coarse tune in semitones. Both always apply to all + // zones of the voice, there is no per-zone counterpart for them + final double coarseTuning = body[offset + 34] + body[offset + 35]; final boolean isFixedPitch = body[offset + 38] == 1; - final int volume = body[offset + 54]; + + // Fine tuning, volume and panning exist twice: at the voice level and in every zone entry. + // The two are alternatives and are never combined - a voice with a single zone stores them + // in its voice parameters and leaves the zone entry at zero, a voice with several zones + // stores the absolute value of each zone in its zone entry and leaves the voice parameters + // at zero (confirmed on E4XT hardware, see git-moss/ConvertWithMoss#220) + final boolean isMultiZone = numZones > 1; + final double voiceFineTune = body[offset + 36] / 64.0; + final int voiceVolume = body[offset + 54]; + final double voicePanning = Math.clamp (body[offset + 55] / 64.0, -1, 1); // The modulation cord table provides the depths of the fixed routings final int modOffset = offset + Emulator4Constants.VOICE_MOD_OFFSET; @@ -521,13 +530,14 @@ else if (destination == 0x38 && source == 0x08) zone.setStart (0); zone.setStop (sample.numFrames); - // The per-zone offsets on top of the settings of the voice: the fine tuning corrects - // the recorded pitch of the individual sample, the volume balances the zones against - // each other and the panning places them in the stereo field - final int zoneFineTune = (short) Emulator4Constants.getU16BE (body, entryOffset + 12); - zone.setTuning (tuning + zoneFineTune / 64.0 + sample.tuning); - zone.setGain (volume + body[entryOffset + 15]); - zone.setPanning (Math.clamp (body[entryOffset + 16] / 64.0, -1, 1)); + // Fine tuning, volume and panning: for a voice with several zones they are the + // absolute values of the zone, which corrects the recorded pitch of the individual + // sample, balances the zones against each other and places them in the stereo field; + // a voice with a single zone has them in its voice parameters instead + final double fineTune = isMultiZone ? (short) Emulator4Constants.getU16BE (body, entryOffset + 12) / 64.0 : voiceFineTune; + zone.setTuning (coarseTuning + fineTune + sample.tuning); + zone.setGain (isMultiZone ? body[entryOffset + 15] : voiceVolume); + zone.setPanning (isMultiZone ? Math.clamp (body[entryOffset + 16] / 64.0, -1, 1) : voicePanning); if (isFixedPitch) zone.setKeyTracking (0); From ead0e071f61fbbefcf16a7edc2acf99badce6493 Mon Sep 17 00:00:00 2001 From: Douglas Carmichael Date: Sun, 26 Jul 2026 11:04:28 -0400 Subject: [PATCH 11/12] E-mu Emulator IV: restrict a zone to the key and velocity window of its voice A zone entry and its voice each carry a key and a velocity window, and the range which actually sounds is their intersection. Many factory presets leave the zone entry wide open at 0-127 and do the whole key split on the voice, e.g. 'Breathy Tenor' of the Studio Essentials CD-ROM, whose 15 voices each hold one zone: voice 0 zone key=0-127 voice key=0-49 voice 1 zone key=0-127 voice key=50-53 voice 2 zone key=0-127 voice key=54-60 ... Reading only the zone entry mapped every one of those samples across the whole keyboard, so all 15 sounded at once on any note instead of one being selected. The same applies to the velocity window, which decides which layer a velocity picks. On the E4 Producer Series CD-ROMs this affected 6.6% of the presets outright and silently widened the layer boundaries of many more. --- .../emu/emulator4/Emulator4Detector.java | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/src/main/java/de/mossgrabers/convertwithmoss/format/emu/emulator4/Emulator4Detector.java b/src/main/java/de/mossgrabers/convertwithmoss/format/emu/emulator4/Emulator4Detector.java index 1bbf953a..89572f1b 100644 --- a/src/main/java/de/mossgrabers/convertwithmoss/format/emu/emulator4/Emulator4Detector.java +++ b/src/main/java/de/mossgrabers/convertwithmoss/format/emu/emulator4/Emulator4Detector.java @@ -462,6 +462,12 @@ private static void parseVoice (final byte [] body, final int offset, final int final int voiceVolume = body[offset + 54]; final double voicePanning = Math.clamp (body[offset + 55] / 64.0, -1, 1); + // The key and velocity window of the voice, which restrict all of its zones + final int voiceKeyLow = body[offset + 14] & 0xFF; + final int voiceKeyHigh = body[offset + 17] & 0xFF; + final int voiceVelocityLow = body[offset + 18] & 0xFF; + final int voiceVelocityHigh = body[offset + 21] & 0xFF; + // The modulation cord table provides the depths of the fixed routings final int modOffset = offset + Emulator4Constants.VOICE_MOD_OFFSET; double velocityToAmplitude = 0; @@ -512,13 +518,19 @@ else if (destination == 0x38 && source == 0x08) continue; } - final int keyLow = body[entryOffset + 2] & 0xFF; - final int keyHigh = body[entryOffset + 5] & 0xFF; + // The zone entry and the voice each carry a key and a velocity window and the range + // which sounds is their intersection. Many presets leave the zone entry wide open at + // 0-127 and do the whole key split on the voice, so ignoring the voice window maps + // every sample across the keyboard and they all sound together on any note + final int keyLow = Math.max (body[entryOffset + 2] & 0xFF, voiceKeyLow); + final int keyHigh = Math.min (body[entryOffset + 5] & 0xFF, voiceKeyHigh); + if (keyLow > keyHigh) + continue; final ISampleZone zone = new DefaultSampleZone (sample.name, Math.min (keyLow, 127), Math.min (keyHigh, 127)); zone.setSampleData (sample.sampleData); - final int velocityLow = body[entryOffset + 6] & 0xFF; - final int velocityHigh = body[entryOffset + 9] & 0xFF; + final int velocityLow = Math.max (body[entryOffset + 6] & 0xFF, voiceVelocityLow); + final int velocityHigh = Math.min (body[entryOffset + 9] & 0xFF, voiceVelocityHigh); if (velocityLow <= velocityHigh && velocityHigh > 0) { zone.setVelocityLow (Math.max (1, velocityLow)); From 2ccefea58d99b02e23c5030ee35b890bb37148fc Mon Sep 17 00:00:00 2001 From: Douglas Carmichael Date: Sun, 26 Jul 2026 11:43:33 -0400 Subject: [PATCH 12/12] E-mu Emulator IV: the loop end is one frame short of the last loop frame The model counts the end of a loop as inclusive, the last frame which is played before playback jumps back to the start. Measuring the step of the waveform at the seam over the presets of the Producer Series CD-ROMs shows a clear optimum one frame later than the stored position: the share of seams which step by more than a third of the peak amplitude drops to zero and the share of seams which are continuous to within 5% rises from 78% to 95%. The same one frame applies to the EIII banks, measured over 2798 of their loops. --- .../format/emu/emulator4/Emulator4Detector.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/main/java/de/mossgrabers/convertwithmoss/format/emu/emulator4/Emulator4Detector.java b/src/main/java/de/mossgrabers/convertwithmoss/format/emu/emulator4/Emulator4Detector.java index 89572f1b..8fce2451 100644 --- a/src/main/java/de/mossgrabers/convertwithmoss/format/emu/emulator4/Emulator4Detector.java +++ b/src/main/java/de/mossgrabers/convertwithmoss/format/emu/emulator4/Emulator4Detector.java @@ -296,7 +296,11 @@ private void parseSample (final byte [] data, final int offset, final int size, if ((options & Emulator4Constants.OPTION_LOOP) > 0) { sample.loopStart = (int) (loopStartOffset - Emulator4Constants.SAMPLE_STRUCT_SIZE) / 2; - sample.loopEnd = Math.min ((int) (loopEndOffset - Emulator4Constants.SAMPLE_STRUCT_SIZE) / 2, numFrames); + // The stored position is the frame before the last one of the loop while the model + // counts the end as inclusive. Measuring the step at the loop seam shows a clear + // optimum at this one frame: the share of seams which step by more than a third of + // the peak amplitude falls to zero and the share of clean ones rises from 78% to 95%. + sample.loopEnd = Math.min ((int) (loopEndOffset - Emulator4Constants.SAMPLE_STRUCT_SIZE) / 2 + 1, numFrames - 1); sample.hasLoop = sample.loopStart >= 0 && sample.loopStart < numFrames && sample.loopEnd > sample.loopStart; }