diff --git a/documentation/CHANGELOG.md b/documentation/CHANGELOG.md
index 820039df..44fb3b98 100644
--- a/documentation/CHANGELOG.md
+++ b/documentation/CHANGELOG.md
@@ -3,7 +3,9 @@
## 19.2.0
* New: Added support for a LFO (low frequency oscillator) modulating pitch (vibrato) with its rate, depth and delay: DecentSampler, DLS, SFZ, SoundFont 2. Previously any vibrato was dropped on conversion.
-
+* 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.
* Kurzweil K2x00
* Fixed: An envelope stage with both a zero time and a zero level is unused on the device and keeps the level of the previous stage, but was read literally. A program which leaves its decay stage unused - like the reported FM basses, which sustain at the attack level and only fade out with a long release - was therefore converted to a silent preset. Additionally, the attack velocity is now converted as a cutoff modulation source of the F1 slot in both directions; the same programs open their nearly closed cutoff by velocity and converted very dull without it.
diff --git a/documentation/README-FORMATS.md b/documentation/README-FORMATS.md
index f8c43753..2d3e0e1e 100644
--- a/documentation/README-FORMATS.md
+++ b/documentation/README-FORMATS.md
@@ -278,6 +278,29 @@ The DLS format (*.dls) is a standardized file format developed for storing and d
There is no write support.
The amplitude and pitch envelopes, the sample loops and a pitch LFO (vibrato) are read. The vibrato's depth, its frequency (converted from absolute pitch cents to Hertz) and its start delay are carried over to the pitch LFO. Only a connection which is not modulated by a controller is read as the vibrato; the format normally contains a second one controlled by the modulation wheel, which is the amount the wheel can dial in and would sound permanently if it were converted.
+## 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*).
+
+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, 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 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.
+
+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 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.
+
+#### 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
@@ -369,9 +392,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/SupportedFeaturesSampleFormats.fods b/documentation/SupportedFeaturesSampleFormats.fods
index ccc75c8e..f3a10dd0 100644
--- a/documentation/SupportedFeaturesSampleFormats.fods
+++ b/documentation/SupportedFeaturesSampleFormats.fods
@@ -3316,6 +3316,150 @@
+
+ E-mu Emulator IV
+ Read
+ e4b
+ Bank with several presets
+ TODO Check for support
+ 16
+ All
+
+
+ Preset name (16 characters)
+ from filename
+
+
+ Bank name (read only)
+ from filename
+
+ Voices
+ Generated ('Voice N')
+ TODO Check for support
+
+ TODO Check for support
+
+
+ Root key
+ Low / high key
+ TODO Check for support
+ Low / high velocity
+ TODO Check for support
+ TODO Check for support
+ TODO Check for support
+ Key transpose + coarse+ fine tune, zone finetune, sample pitch offset
+ Non-transpose flag(on / off)
+ TODO Check for support
+ Voice + zone volume (dB)
+ Zone pan (-64 ... +63)
+ TODO Check for support
+ TODO Check for support
+ TODO Check for support
+ TODO Check for support
+
+
+ Forward
+ TODO Check for support
+ Loop start
+ Loop end
+ TODO Check for support
+ TODO Check for support
+
+
+ 6-stage envelope (attack,hold, decay, sustain,release)
+ TODO Check for support
+ Velocity to Amp cord
+ TODO Check for support
+ TODO Check for support
+
+
+ TODO Check for support
+ TODO Check for support
+ TODO Check for support
+ TODO Check for support
+
+
+ Lowpass / Highpass /Bandpass / Contrary BP(effect types skipped)
+ 2 / 4 / 6
+ Cutoff (57 Hz - 20 kHz)
+ Resonance
+ 6-stage envelope +Cord 5 depth
+ TODO Check for support
+ Key to Cutoff cord
+
+
+
+
+ Write
+
+
+
+ 16
+ Up to 48 kHz
+
+
+
+
+
+
+
+
+
+ One voice per zone
+ TODO Check for support
+
+
+
+
+
+
+
+
+
+
+
+
+ Coarse + fine tune
+ Non-transpose flag(on / off)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Lowpass / Highpass /Bandpass / Contrary BP
+ Nearest of 2 / 4 / 6
+
+
+
+
+
+
+ Ensoniq EPS/EPS16+/ASR-10
diff --git a/documentation/design/E4B_FORMAT.md b/documentation/design/E4B_FORMAT.md
new file mode 100644
index 00000000..5e6ffe46
--- /dev/null
+++ b/documentation/design/E4B_FORMAT.md
@@ -0,0 +1,312 @@
+# 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) - 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) - 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 |
+
+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) |
+| 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) - 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 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
+ 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)
+
+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 - 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 |
+
+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
+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 (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
+```
+
+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.
+
+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
+ 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, 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
+ 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).
+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/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/Emu3DiskImage.java b/src/main/java/de/mossgrabers/convertwithmoss/format/emu/emulator4/Emu3DiskImage.java
new file mode 100644
index 00000000..a56e8361
--- /dev/null
+++ b/src/main/java/de/mossgrabers/convertwithmoss/format/emu/emulator4/Emu3DiskImage.java
@@ -0,0 +1,454 @@
+// 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.io.RandomAccessFile;
+import java.nio.file.Files;
+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;
+ }
+
+
+ ////////////////////////////////////////////////////////////////////////////////////
+ // 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;
+ // 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 - 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;
+ 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/Emulator4Constants.java b/src/main/java/de/mossgrabers/convertwithmoss/format/emu/emulator4/Emulator4Constants.java
new file mode 100644
index 00000000..a6815217
--- /dev/null
+++ b/src/main/java/de/mossgrabers/convertwithmoss/format/emu/emulator4/Emulator4Constants.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.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 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;
+ 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);
+ }
+
+
+ /**
+ * 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.
+ *
+ * @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. 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
+ * @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;
+ if (c == 0)
+ break;
+ 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..b04ab2da
--- /dev/null
+++ b/src/main/java/de/mossgrabers/convertwithmoss/format/emu/emulator4/Emulator4Creator.java
@@ -0,0 +1,742 @@
+// 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.ByteArrayOutputStream;
+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.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
+{
+ /** 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
+ }, MAX_SAMPLE_RATE, 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, new Emulator4CreatorUI ("E4B"));
+ }
+
+
+ /** {@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 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<> ();
+ final Map