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 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; + } + + // Samples above the maximum EOS rate are down-sampled - move the positions with them + recalculateAllSamplePositions (multisampleSource, MAX_SAMPLE_RATE, true); + + 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; + } + + // The written file is the bank, so its 16 character preset names do not need to repeat + // the bank a source came from - which would only leave room for the bank itself + final String presetName = createPresetName (multisampleSource); + presetBodies.add (createPresetBody (presetBodies.size (), presetName, voices)); + presetNames.add (presetName); + } + + if (presetBodies.isEmpty ()) + return; + + if (writeCdImage) + { + final ByteArrayOutputStream bankData = new ByteArrayOutputStream (); + this.writeFile (bankData, presetBodies, presetNames, samples); + Emu3DiskImage.writeImage (outputFile, List.of (new Emu3DiskImage.ImageFile (safeName, bankData.toByteArray ()))); + } + else + try (final OutputStream out = new BufferedOutputStream (Files.newOutputStream (outputFile.toPath ()))) + { + this.writeFile (out, presetBodies, presetNames, samples); + } + this.notifier.log ("IDS_NOTIFY_PROGRESS_DONE"); + } + + + /** + * Get the name to write for a preset. The detector puts the bank of a source in front of its + * name, so that a preset which is only named after its articulation ('Dark Tremolo') stays + * identifiable and unique. The written file is that bank, so its preset names do not need to + * repeat it - and could not: they hold 16 characters, which the bank alone would fill. + * + * @param multisampleSource The multi-sample source + * @return The name of the preset + */ + private static String createPresetName (final IMultisampleSource multisampleSource) + { + final String name = multisampleSource.getName (); + final String bank = multisampleSource.getMetadata ().getDescription (); + if (name == null || bank == null || bank.isBlank ()) + return name; + final String prefix = bank + " - "; + return name.startsWith (prefix) && name.length () > 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. + * + * @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; + // 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) + 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); + // 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; + } + + + /** + * Assemble and write the bank. + * + * @param outputStream Where to write the bank to + * @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 bank + */ + 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 + 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]; + + 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) + { + 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/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/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..8fce2451 --- /dev/null +++ b/src/main/java/de/mossgrabers/convertwithmoss/format/emu/emulator4/Emulator4Detector.java @@ -0,0 +1,661 @@ +// 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.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.TreeSet; +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) 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 + */ +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; + double tuning; + boolean hasLoop; + int loopStart; + int loopEnd; + } + + + /** + * Constructor. + * + * @param notifier The notifier + */ + public Emulator4Detector (final INotifier notifier) + { + super ("E-mu Emulator IV", "E4B", notifier, new Emulator4DetectorUI ("E4B"), ".e4b", ".iso", ".img", ".hda"); + } + + + /** {@inheritDoc} */ + @Override + public List readPresetFile (final File sourceFile) + { + if (this.waitForDelivery ()) + return Collections.emptyList (); + + 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, FileUtils.getNameWithoutType (sourceFile), data); + } + catch (final IOException ex) + { + this.notifier.logError ("IDS_NOTIFY_ERR_LOAD_FILE", ex); + return Collections.emptyList (); + } + } + + + /** + * Parse all banks of an EOS disk image and create one multi-sample source per preset. + * + * @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 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 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", bankName); + 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, bankName, presetChunk, samplesByIndex); + if (multisampleSource != null) + results.add (multisampleSource); + } + if (results.isEmpty ()) + this.notifier.logError ("IDS_E4B_NO_PRESETS", bankName); + else + this.notifier.log ("IDS_E4B_READING_BANK", bankName, 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 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; + 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 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; + 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; + // 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; + } + + 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 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 String bankName, 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 Set missingSampleIndices = new TreeSet<> (); + 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)); + parseVoice (body, offset, numZones, samplesByIndex, missingSampleIndices, 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 + } + + // 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; + for (final Integer missingSampleIndex: missingSampleIndices) + this.notifier.logError ("IDS_E4B_SAMPLE_MISSING", missingSampleIndex.toString (), presetName); + + // 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; + } + + + /** + * 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 + * @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, final boolean prependBankName) + { + if (!prependBankName || 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; + } + + + /** + * 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 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 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. 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; + + // 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 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; + 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; + // 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) + { + missingSampleIndices.add (Integer.valueOf (sampleIndex)); + continue; + } + + // 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 = 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)); + 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); + + // 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); + + 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/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/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 67dd9be7..7a0c23b7 100644 --- a/src/main/resources/Strings.properties +++ b/src/main/resources/Strings.properties @@ -382,6 +382,23 @@ 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_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 +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_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 + 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