diff --git a/documentation/CHANGELOG.md b/documentation/CHANGELOG.md index cc20b6cf..b0d17806 100644 --- a/documentation/CHANGELOG.md +++ b/documentation/CHANGELOG.md @@ -2,6 +2,7 @@ ## 18.2.0 (unreleased) +* New: Added support for the Polyend Tracker instrument format (PTI) for both reading and writing. A PTI file holds a single 16-bit / 44.1kHz sample (mono or stereo); the play mode, the playback start/end and loop points, the loop mode (forward / backward / ping-pong), a filter (low-, high- and band-pass with cutoff, resonance and a cutoff envelope), the amplitude and pitch envelopes as well as the volume, panning and tuning are translated. Instruments in a slice play mode are split into one trimmed sample zone per slice, mapped chromatically from MIDI note 60. When writing a multi-sample, the zone covering note 60 (otherwise the first zone) is stored since the format holds only a single sample. This has **not** been verified on physical Polyend Tracker hardware; it was validated against the official tracker-lib reference implementation and round-trip conversions. * New: Added support for the Renoise instrument format (XRNI) for both reading and writing. The key/velocity mapping, root note, tuning, volume, panning, loops, the amplitude envelope, a per-sample sampler filter (type, cutoff and resonance with a cutoff envelope), a pitch envelope and round-robins are translated. The filter is written as the native sampler filter (including the required mixer modulation device) so the instruments load correctly. Created files use document version 33 so they load in Renoise as well as the Renoise Redux plug-in. By default loops are written exactly (faithful); if the loop cross-fade processing option is enabled the cross-fade is baked into the looped samples since Renoise has no cross-fade parameter. Samples which the bundled FLAC encoder cannot encode are stored as WAV instead so the conversion does not fail. * New: Added several new tags for category detection. * Fixed: FLAC or OGG samples stored inside a ZIP archive (e.g. discoDSP Bliss or DecentSampler libraries) could fail to decompress with a 'mark/reset not supported' error. diff --git a/documentation/README-FORMATS.md b/documentation/README-FORMATS.md index 38431684..7a3be568 100644 --- a/documentation/README-FORMATS.md +++ b/documentation/README-FORMATS.md @@ -57,6 +57,7 @@ The following multi-sample formats are supported: * [Logic EXS24](#logic-exs24) * [Native Instruments Kontakt](#native-instruments-kontakt) * [Native Instruments Maschine](#native-instruments-maschine) +* [Polyend Tracker](#polyend-tracker) * [Propellerhead Reason NN-XT](#propellerhead-reason-nn-xt) * [Roland S-50 Series](#roland-s-50-series) - read only * [Roland S-770 Series](#roland-s-770-series) - read only @@ -417,6 +418,22 @@ Note that Maschine contains an auto-sampler with which you can sample plugins or * Output Format: Select the Maschine output format. Selecting **Maschine 1** will create a MSND file, otherwise a MXSND file is created. +## Polyend Tracker + +The Polyend Tracker (and the Tracker Mini / Tracker+) is a standalone hardware sampler, sequencer and tracker. Its instrument format (file ending *pti*) is a single binary file which holds exactly one 16-bit / 44.1kHz PCM sample (mono or stereo) together with the instrument parameters. Both reading and writing are supported. + +When reading, the play mode, the playback start/end and loop points, the loop mode (forward / backward / ping-pong), the filter (low-, high- and band-pass with cutoff, resonance and the cutoff envelope), the amplitude and pitch envelopes as well as the volume, panning and tuning are converted. Instruments in one of the slice play modes are split into one sample zone per slice; each slice is trimmed to its own audio and mapped chromatically to a single key starting at MIDI note 60 (middle C). + +When writing, the play mode (one-shot or one of the loop modes), the start/end and loop points, the filter, the amplitude/cutoff/pitch envelopes as well as the volume, panning and tuning are stored. The audio is converted to 16-bit / 44.1kHz; mono or stereo is preserved. + +### Limitations + +* A Polyend Tracker instrument holds only a single sample. When converting a multi-sample, the zone whose key range covers MIDI note 60 (middle C) is stored - otherwise the first zone - and all other zones are ignored (a note is logged). +* The wavetable and granular play modes are read as a plain one-shot sample. The wavetable/granular specific parameters, the LFOs and the delay/reverb sends and overdrive are not converted. +* The playback start/end, loop and slice positions are stored proportionally to the sample length (0 to 65535). Loop points can therefore differ by a tiny fraction of the sample length after a round-trip. +* The filter cutoff frequency mapping is an approximation since the Tracker's normalized cutoff to frequency curve is internal and not part of the format. +* This format has **not** been verified on physical Polyend Tracker hardware. It was validated against the official *tracker-lib* reference implementation and round-trip conversions. + ## Propellerhead Reason NN-XT The Propellerhead Reason NN-XT is a software sampler that is included in the Reason software package. Reason is a digital audio workstation (DAW) software developed by Propellerhead Software. It allows users to load and play back sampled sounds, such as instruments or drum hits. The file ending is *sxt*. diff --git a/src/main/java/de/mossgrabers/convertwithmoss/core/ConverterBackend.java b/src/main/java/de/mossgrabers/convertwithmoss/core/ConverterBackend.java index 8b8dba12..481154b6 100644 --- a/src/main/java/de/mossgrabers/convertwithmoss/core/ConverterBackend.java +++ b/src/main/java/de/mossgrabers/convertwithmoss/core/ConverterBackend.java @@ -66,6 +66,8 @@ import de.mossgrabers.convertwithmoss.format.ni.maschine.MaschineDetector; import de.mossgrabers.convertwithmoss.format.omnisphere.OmnisphereCreator; import de.mossgrabers.convertwithmoss.format.omnisphere.OmnisphereDetector; +import de.mossgrabers.convertwithmoss.format.polyend.PolyendTrackerCreator; +import de.mossgrabers.convertwithmoss.format.polyend.PolyendTrackerDetector; import de.mossgrabers.convertwithmoss.format.renoise.RenoiseCreator; import de.mossgrabers.convertwithmoss.format.renoise.RenoiseDetector; import de.mossgrabers.convertwithmoss.format.roland.s5xx.S5xxDetector; @@ -150,6 +152,7 @@ public ConverterBackend (final INotifier notifier) new KontaktDetector (notifier), new MaschineDetector (notifier), new OmnisphereDetector (notifier), + new PolyendTrackerDetector (notifier), new RenoiseDetector (notifier), new S5xxDetector (notifier), new S770Detector (notifier), @@ -180,6 +183,7 @@ public ConverterBackend (final INotifier notifier) new KontaktCreator (notifier), new MaschineCreator (notifier), new OmnisphereCreator (notifier), + new PolyendTrackerCreator (notifier), new RenoiseCreator (notifier), new SxtCreator (notifier), new WavCreator (notifier), diff --git a/src/main/java/de/mossgrabers/convertwithmoss/format/polyend/PolyendTrackerConstants.java b/src/main/java/de/mossgrabers/convertwithmoss/format/polyend/PolyendTrackerConstants.java new file mode 100644 index 00000000..bda97cc1 --- /dev/null +++ b/src/main/java/de/mossgrabers/convertwithmoss/format/polyend/PolyendTrackerConstants.java @@ -0,0 +1,213 @@ +// Written by Jürgen Moßgraber - mossgrabers.de +// (c) 2019-2026 +// Licensed under LGPLv3 - http://www.gnu.org/licenses/lgpl-3.0.txt + +package de.mossgrabers.convertwithmoss.format.polyend; + +/** + * Binary layout constants of the Polyend Tracker instrument format (file ending .pti). The + * file is a fixed size header (16 bytes) followed by 372 bytes of instrument parameters, the raw + * 16-bit / 44.1kHz PCM audio data (mono or stereo, stored non-interleaved) and a trailing 4 byte + * checksum. All multi-byte values are little-endian. + * + * @author Jürgen Moßgraber + */ +public final class PolyendTrackerConstants +{ + /** The file identifier of an instrument file. */ + public static final String FILE_ID = "TI"; + /** The file type of an instrument. */ + public static final int TYPE_INSTRUMENT = 1; + + /** The size of the header. */ + public static final int HEADER_SIZE = 16; + /** The value of the size field (the number of bytes of the parameter block). */ + public static final int PARAMETER_BLOCK_SIZE = 372; + /** The offset at which the raw audio data starts (header + parameter block). */ + public static final int AUDIO_START = HEADER_SIZE + PARAMETER_BLOCK_SIZE; + /** The number of bytes of the trailing checksum. */ + public static final int CRC_SIZE = 4; + + /** Sample start/end and loop points as well as slices are stored as a value in the range of 0 to + * this value, proportional to the length of the sample. */ + public static final int NORMALIZED_MAX = 65535; + /** The sample rate of all instrument samples is fixed to 44100 Hz. */ + public static final int SAMPLE_RATE = 44100; + /** The bit resolution of all instrument samples is fixed to 16 bit. */ + public static final int BIT_RESOLUTION = 16; + + /** The number of slice positions stored in a file (only the first {@code numSlices} are used). */ + public static final int SLICE_COUNT = 48; + /** The number of envelopes (one for each automation target). */ + public static final int ENVELOPE_COUNT = 6; + /** The number of LFOs (one for each automation target). */ + public static final int LFO_COUNT = 6; + + // Offsets into the parameter block (absolute file offsets) + /** Offset of the 'is active' flag. */ + public static final int OFF_IS_ACTIVE = 16; + /** Offset of the sample type (0 = wave file, 1 = wavetable). */ + public static final int OFF_SAMPLE_TYPE = 20; + /** Offset of the sample name (32 bytes, null terminated ASCII). */ + public static final int OFF_SAMPLE_NAME = 21; + /** The maximum number of bytes of the sample name. */ + public static final int SAMPLE_NAME_LENGTH = 32; + /** Offset of the sample length (number of frames, unsigned 32-bit). */ + public static final int OFF_SAMPLE_LENGTH = 60; + /** Offset of the wavetable window size (unsigned 16-bit). */ + public static final int OFF_WT_WINDOW_SIZE = 64; + /** Offset of the wavetable window count (unsigned 32-bit). */ + public static final int OFF_WT_WINDOW_COUNT = 68; + /** Offset of the play mode. */ + public static final int OFF_PLAYMODE = 76; + /** Offset of the playback start point (normalized). */ + public static final int OFF_START = 78; + /** Offset of loop point 1 - the loop start (normalized). */ + public static final int OFF_LOOP1 = 80; + /** Offset of loop point 2 - the loop end (normalized). */ + public static final int OFF_LOOP2 = 82; + /** Offset of the playback end point (normalized). */ + public static final int OFF_END = 84; + /** Offset of the first envelope. */ + public static final int OFF_ENVELOPES = 92; + /** The number of bytes of one envelope. */ + public static final int ENVELOPE_SIZE = 20; + /** Offset of the first LFO. */ + public static final int OFF_LFOS = 212; + /** The number of bytes of one LFO. */ + public static final int LFO_SIZE = 8; + /** Offset of the filter cutoff (32-bit float in the range of 0 to 1). */ + public static final int OFF_CUTOFF = 260; + /** Offset of the filter resonance (32-bit float in the range of 0 to 4.3). */ + public static final int OFF_RESONANCE = 264; + /** Offset of the filter type. */ + public static final int OFF_FILTER_TYPE = 268; + /** Offset of the filter enabled flag. */ + public static final int OFF_FILTER_ENABLED = 269; + /** Offset of the coarse tune (signed 8-bit, semitones). */ + public static final int OFF_TUNE = 270; + /** Offset of the fine tune (signed 8-bit, cents). */ + public static final int OFF_FINETUNE = 271; + /** Offset of the volume (unsigned 8-bit, 0 to 100, 50 = unity gain). */ + public static final int OFF_VOLUME = 272; + /** Offset of the panning (signed 16-bit, 0 to 100, 50 = center). */ + public static final int OFF_PANNING = 276; + /** Offset of the delay send (unsigned 8-bit, 0 to 100). */ + public static final int OFF_DELAY_SEND = 278; + /** Offset of the first slice position (normalized, unsigned 16-bit). */ + public static final int OFF_SLICES = 280; + /** Offset of the number of used slices (unsigned 8-bit). */ + public static final int OFF_NUM_SLICES = 376; + /** Offset of the selected slice (unsigned 8-bit). */ + public static final int OFF_SELECTED_SLICE = 377; + /** Offset of the granular grain length (unsigned 16-bit). */ + public static final int OFF_GRANULAR_LENGTH = 378; + /** Offset of the granular position (unsigned 16-bit). */ + public static final int OFF_GRANULAR_POSITION = 380; + /** Offset of the granular shape. */ + public static final int OFF_GRANULAR_SHAPE = 382; + /** Offset of the granular loop type. */ + public static final int OFF_GRANULAR_TYPE = 383; + /** Offset of the reverb send (unsigned 8-bit, 0 to 100). */ + public static final int OFF_REVERB_SEND = 384; + /** Offset of the overdrive (unsigned 8-bit, 0 to 100). */ + public static final int OFF_OVERDRIVE = 385; + /** Offset of the bit depth (unsigned 8-bit, 4 to 16). */ + public static final int OFF_BITDEPTH = 386; + + // Offsets of the fields inside one envelope (relative to the start of the envelope) + /** Relative offset of the envelope amount (32-bit float). */ + public static final int ENV_OFF_AMOUNT = 0; + /** Relative offset of the envelope delay (unsigned 16-bit, milliseconds). */ + public static final int ENV_OFF_DELAY = 4; + /** Relative offset of the envelope attack (unsigned 16-bit, milliseconds). */ + public static final int ENV_OFF_ATTACK = 6; + /** Relative offset of the envelope hold (unsigned 16-bit, milliseconds). */ + public static final int ENV_OFF_HOLD = 8; + /** Relative offset of the envelope decay (unsigned 16-bit, milliseconds). */ + public static final int ENV_OFF_DECAY = 10; + /** Relative offset of the envelope sustain (32-bit float, 0 to 1). */ + public static final int ENV_OFF_SUSTAIN = 12; + /** Relative offset of the envelope release (unsigned 16-bit, milliseconds). */ + public static final int ENV_OFF_RELEASE = 16; + /** Relative offset of the LFO flag (1 = the automation uses the LFO instead of the envelope). */ + public static final int ENV_OFF_LFO_FLAG = 18; + /** Relative offset of the envelope enabled flag. */ + public static final int ENV_OFF_ENABLED = 19; + + // Automation / envelope indices + /** The volume (amplitude) automation. */ + public static final int ENV_VOLUME = 0; + /** The panning automation. */ + public static final int ENV_PANNING = 1; + /** The filter cutoff automation. */ + public static final int ENV_CUTOFF = 2; + /** The wavetable position automation. */ + public static final int ENV_WAVETABLE = 3; + /** The granular position automation. */ + public static final int ENV_GRANULAR = 4; + /** The fine tune (pitch) automation. */ + public static final int ENV_FINETUNE = 5; + + // Play modes + /** Play the sample once. */ + public static final int PLAYMODE_ONESHOT = 0; + /** Loop the sample forward. */ + public static final int PLAYMODE_FORWARD_LOOP = 1; + /** Loop the sample backward. */ + public static final int PLAYMODE_BACKWARD_LOOP = 2; + /** Loop the sample alternating forward and backward. */ + public static final int PLAYMODE_PINGPONG_LOOP = 3; + /** Play a slice of the sample. */ + public static final int PLAYMODE_SLICE = 4; + /** Play a slice of the sample, chromatically mapped. */ + public static final int PLAYMODE_BEAT_SLICE = 5; + /** Play the sample as a wavetable. */ + public static final int PLAYMODE_WAVETABLE = 6; + /** Play the sample granular. */ + public static final int PLAYMODE_GRANULAR = 7; + + // Filter types + /** A low-pass filter. */ + public static final int FILTER_LOWPASS = 0; + /** A high-pass filter. */ + public static final int FILTER_HIGHPASS = 1; + /** A band-pass filter. */ + public static final int FILTER_BANDPASS = 2; + + // Sample types + /** A normal wave file. */ + public static final int SAMPLE_WAVE = 0; + /** A wavetable. */ + public static final int SAMPLE_WAVETABLE = 1; + + /** The firmware version bytes written into created files. */ + public static final int [] WRITE_FW_VERSION = + { + 1, + 9, + 1, + 1 + }; + /** The file structure version bytes written into created files (matches the value found in all + * factory files). */ + public static final int [] WRITE_STRUCTURE_VERSION = + { + 9, + 9, + 9, + 9 + }; + + /** The default root note used for an imported sample (middle C). */ + public static final int DEFAULT_ROOT_NOTE = 60; + + + /** + * Constructor. + */ + private PolyendTrackerConstants () + { + // Helper class + } +} diff --git a/src/main/java/de/mossgrabers/convertwithmoss/format/polyend/PolyendTrackerCreator.java b/src/main/java/de/mossgrabers/convertwithmoss/format/polyend/PolyendTrackerCreator.java new file mode 100644 index 00000000..255965fb --- /dev/null +++ b/src/main/java/de/mossgrabers/convertwithmoss/format/polyend/PolyendTrackerCreator.java @@ -0,0 +1,413 @@ +// 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.polyend; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Optional; + +import de.mossgrabers.convertwithmoss.core.IMultisampleSource; +import de.mossgrabers.convertwithmoss.core.INotifier; +import de.mossgrabers.convertwithmoss.core.creator.AbstractCreator; +import de.mossgrabers.convertwithmoss.core.creator.DestinationAudioFormat; +import de.mossgrabers.convertwithmoss.core.model.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.settings.EmptySettingsUI; +import de.mossgrabers.convertwithmoss.file.AudioFileUtils; +import de.mossgrabers.convertwithmoss.file.wav.FormatChunk; +import de.mossgrabers.convertwithmoss.file.wav.WaveFile; + + +/** + * Creator for Polyend Tracker instrument files (file ending .pti). A PTI file holds exactly + * one sample, therefore only one representative sample zone of the multi-sample is stored. The audio + * is converted to 16-bit / 44.1kHz (mono or stereo). + * + * @author Jürgen Moßgraber + */ +public class PolyendTrackerCreator extends AbstractCreator +{ + /** A PTI file always stores 16-bit / 44.1kHz audio. */ + private static final DestinationAudioFormat DESTINATION_AUDIO_FORMAT = new DestinationAudioFormat (new int [] + { + PolyendTrackerConstants.BIT_RESOLUTION + }, PolyendTrackerConstants.SAMPLE_RATE, true); + + private static final int DEFAULT_GRAIN_LENGTH = 4410; + private static final int GRANULAR_SHAPE_TRIANGLE = 1; + private static final int LFO_SHAPE_TRIANGLE = 2; + private static final int LFO_SPEED_S4 = 10; + + + /** + * Constructor. + * + * @param notifier The notifier + */ + public PolyendTrackerCreator (final INotifier notifier) + { + super ("Polyend Tracker", "PolyendTracker", notifier, EmptySettingsUI.INSTANCE); + } + + + /** {@inheritDoc} */ + @Override + public void createPreset (final File destinationFolder, final IMultisampleSource multisampleSource) throws IOException + { + final ISampleZone zone = pickRepresentativeZone (multisampleSource); + if (zone == null) + { + this.notifier.logError (IDS_NOTIFY_ERR_MISSING_SAMPLE_DATA, multisampleSource.getName (), "-"); + return; + } + + if (countZones (multisampleSource) > 1) + this.notifier.log ("IDS_PTI_ONLY_ONE_SAMPLE", zone.getName ()); + + final ISampleData sampleData = zone.getSampleData (); + if (sampleData == null) + { + this.notifier.logError (IDS_NOTIFY_ERR_MISSING_SAMPLE_DATA, zone.getName (), "-"); + return; + } + + final WaveFile waveFile = AudioFileUtils.convertToWav (sampleData, DESTINATION_AUDIO_FORMAT); + final FormatChunk formatChunk = waveFile.getFormatChunk (); + final int channels = formatChunk.getNumberOfChannels (); + if (channels < 1 || channels > 2) + { + this.notifier.logError ("IDS_PTI_UNSUPPORTED_CHANNELS", Integer.toString (channels)); + return; + } + + final byte [] pcm = waveFile.getDataChunk ().getData (); + final int frames = pcm.length / (channels * 2); + if (frames <= 0) + { + this.notifier.logError (IDS_NOTIFY_ERR_MISSING_SAMPLE_DATA, zone.getName (), "-"); + return; + } + + final File file = this.createUniqueFilename (destinationFolder, createSafeFilename (multisampleSource.getName ()), "pti"); + this.notifier.log ("IDS_NOTIFY_STORING", file.getAbsolutePath ()); + + final byte [] output = createInstrument (multisampleSource.getName (), zone, sampleData, pcm, channels, frames); + try (final FileOutputStream out = new FileOutputStream (file)) + { + out.write (output); + } + + this.progress.notifyDone (); + } + + + /** + * Create the binary content of the PTI file. + * + * @param name The name of the instrument + * @param zone The sample zone to store + * @param sampleData The original sample data (used to read the original frame count) + * @param pcm The (16-bit / 44.1kHz) interleaved PCM audio data + * @param channels The number of channels (1 or 2) + * @param frames The number of frames in the converted audio + * @return The binary content + * @throws IOException Could not read the audio metadata + */ + private static byte [] createInstrument (final String name, final ISampleZone zone, final ISampleData sampleData, final byte [] pcm, final int channels, final int frames) throws IOException + { + final int audioBytes = frames * channels * 2; + final byte [] output = new byte [PolyendTrackerConstants.AUDIO_START + audioBytes + PolyendTrackerConstants.CRC_SIZE]; + final ByteBuffer buffer = ByteBuffer.wrap (output).order (ByteOrder.LITTLE_ENDIAN); + + writeHeader (buffer); + + buffer.put (PolyendTrackerConstants.OFF_IS_ACTIVE, (byte) 1); + buffer.put (PolyendTrackerConstants.OFF_SAMPLE_TYPE, (byte) PolyendTrackerConstants.SAMPLE_WAVE); + writeString (buffer, PolyendTrackerConstants.OFF_SAMPLE_NAME, PolyendTrackerConstants.SAMPLE_NAME_LENGTH, createSafeFilename (name)); + buffer.putInt (PolyendTrackerConstants.OFF_SAMPLE_LENGTH, frames); + buffer.putShort (PolyendTrackerConstants.OFF_WT_WINDOW_SIZE, (short) 2048); + buffer.putInt (PolyendTrackerConstants.OFF_WT_WINDOW_COUNT, 0); + + // Playback range and loop. The normalized points are proportional to the sample length and + // therefore independent of the (possibly resampled) frame count. + final int totalFrames = sampleData.getAudioMetadata ().getNumberOfSamples () > 0 ? sampleData.getAudioMetadata ().getNumberOfSamples () : frames; + final int start = Math.max (0, zone.getStart ()); + final int stop = zone.getStop () <= 0 ? totalFrames : zone.getStop (); + buffer.putShort (PolyendTrackerConstants.OFF_START, (short) PolyendTrackerValueConverter.frameToNormalized (start, totalFrames)); + buffer.putShort (PolyendTrackerConstants.OFF_END, (short) PolyendTrackerValueConverter.frameToNormalized (stop, totalFrames)); + + final List loops = zone.getLoops (); + final ISampleLoop loop = loops.isEmpty () ? null : loops.get (0); + if (loop != null && loop.getEnd () > loop.getStart ()) + { + buffer.put (PolyendTrackerConstants.OFF_PLAYMODE, (byte) loopPlaymode (loop)); + buffer.putShort (PolyendTrackerConstants.OFF_LOOP1, (short) PolyendTrackerValueConverter.frameToNormalized (Math.max (0, loop.getStart ()), totalFrames)); + buffer.putShort (PolyendTrackerConstants.OFF_LOOP2, (short) PolyendTrackerValueConverter.frameToNormalized (loop.getEnd (), totalFrames)); + } + else + { + buffer.put (PolyendTrackerConstants.OFF_PLAYMODE, (byte) PolyendTrackerConstants.PLAYMODE_ONESHOT); + buffer.putShort (PolyendTrackerConstants.OFF_LOOP1, (short) 0); + buffer.putShort (PolyendTrackerConstants.OFF_LOOP2, (short) PolyendTrackerConstants.NORMALIZED_MAX); + } + + writeEnvelopesAndLFOs (buffer, zone); + writeFilter (buffer, zone); + + buffer.put (PolyendTrackerConstants.OFF_TUNE, (byte) PolyendTrackerValueConverter.tuningToTune (zone.getTuning ())); + buffer.put (PolyendTrackerConstants.OFF_FINETUNE, (byte) PolyendTrackerValueConverter.tuningToFinetune (zone.getTuning ())); + buffer.put (PolyendTrackerConstants.OFF_VOLUME, (byte) PolyendTrackerValueConverter.gainToRawVolume (zone.getGain ())); + buffer.putShort (PolyendTrackerConstants.OFF_PANNING, (short) PolyendTrackerValueConverter.modelPanningToRaw (zone.getPanning ())); + + // Granular defaults (only relevant in granular play mode) + buffer.putShort (PolyendTrackerConstants.OFF_GRANULAR_LENGTH, (short) DEFAULT_GRAIN_LENGTH); + buffer.put (PolyendTrackerConstants.OFF_GRANULAR_SHAPE, (byte) GRANULAR_SHAPE_TRIANGLE); + + buffer.put (PolyendTrackerConstants.OFF_BITDEPTH, (byte) PolyendTrackerConstants.BIT_RESOLUTION); + + // Audio data. A stereo sample is stored non-interleaved (the complete left channel followed + // by the complete right channel); a mono sample is copied unchanged. The trailing checksum + // is left as zero which is accepted by the hardware (factory files use a zero checksum too). + writeAudio (output, pcm, channels, frames); + + return output; + } + + + /** + * Write the 16 byte header. + * + * @param buffer The output buffer + */ + private static void writeHeader (final ByteBuffer buffer) + { + buffer.put (0, (byte) 'T'); + buffer.put (1, (byte) 'I'); + buffer.putShort (2, (short) PolyendTrackerConstants.TYPE_INSTRUMENT); + for (int i = 0; i < 4; i++) + { + buffer.put (4 + i, (byte) PolyendTrackerConstants.WRITE_FW_VERSION[i]); + buffer.put (8 + i, (byte) PolyendTrackerConstants.WRITE_STRUCTURE_VERSION[i]); + } + buffer.putShort (12, (short) PolyendTrackerConstants.PARAMETER_BLOCK_SIZE); + } + + + /** + * Write the 6 envelopes and 6 LFOs. The volume envelope is taken from the amplitude envelope of + * the zone, the cutoff envelope from the filter (if any) and the fine tune envelope from the + * pitch envelope (if a depth is set). All others are written with sane disabled defaults. + * + * @param buffer The output buffer + * @param zone The zone + */ + private static void writeEnvelopesAndLFOs (final ByteBuffer buffer, final ISampleZone zone) + { + // Disabled defaults for all automation slots + for (int slot = 0; slot < PolyendTrackerConstants.ENVELOPE_COUNT; slot++) + writeEnvelope (buffer, slot, null, false, 1.0); + + // The volume envelope is always enabled + writeEnvelope (buffer, PolyendTrackerConstants.ENV_VOLUME, zone.getAmplitudeEnvelopeModulator ().getSource (), true, 1.0); + + final Optional optionalFilter = zone.getFilter (); + if (optionalFilter.isPresent ()) + { + final IFilter filter = optionalFilter.get (); + final IEnvelopeModulator cutoffModulator = filter.getCutoffEnvelopeModulator (); + if (cutoffModulator.getDepth () != 0) + { + final double cutoff = PolyendTrackerValueConverter.hertzToNormalizedCutoff (filter.getCutoff ()); + final double amount = Math.clamp (PolyendTrackerValueConverter.hertzToNormalizedCutoff (filter.getCutoff () + cutoffModulator.getDepth ()) - cutoff, 0.0, 1.0); + writeEnvelope (buffer, PolyendTrackerConstants.ENV_CUTOFF, cutoffModulator.getSource (), true, amount); + } + } + + final IEnvelopeModulator pitchModulator = zone.getPitchEnvelopeModulator (); + if (pitchModulator.getDepth () != 0) + writeEnvelope (buffer, PolyendTrackerConstants.ENV_FINETUNE, pitchModulator.getSource (), true, 1.0); + + // LFO defaults + for (int i = 0; i < PolyendTrackerConstants.LFO_COUNT; i++) + { + final int base = PolyendTrackerConstants.OFF_LFOS + i * PolyendTrackerConstants.LFO_SIZE; + buffer.put (base, (byte) LFO_SHAPE_TRIANGLE); + buffer.put (base + 1, (byte) LFO_SPEED_S4); + } + } + + + /** + * Write one envelope. + * + * @param buffer The output buffer + * @param slot The automation slot index + * @param envelope The envelope (may be null for a disabled default) + * @param enabled True to enable the envelope + * @param amount The envelope amount (0 to 1) + */ + private static void writeEnvelope (final ByteBuffer buffer, final int slot, final IEnvelope envelope, final boolean enabled, final double amount) + { + final int base = PolyendTrackerConstants.OFF_ENVELOPES + slot * PolyendTrackerConstants.ENVELOPE_SIZE; + + final double delay = envelope == null ? 0 : envelope.getDelayTime (); + final double attack = envelope == null ? 0 : envelope.getAttackTime (); + final double hold = envelope == null ? 0 : envelope.getHoldTime (); + final double decay = envelope == null ? 0 : envelope.getDecayTime (); + final double sustainValue = envelope == null ? 1.0 : envelope.getSustainLevel (); + final double release = envelope == null ? 1.0 : envelope.getReleaseTime (); + + buffer.putFloat (base + PolyendTrackerConstants.ENV_OFF_AMOUNT, (float) Math.clamp (amount, 0.0, 1.0)); + buffer.putShort (base + PolyendTrackerConstants.ENV_OFF_DELAY, (short) PolyendTrackerValueConverter.secondsToMilliseconds (delay)); + buffer.putShort (base + PolyendTrackerConstants.ENV_OFF_ATTACK, (short) PolyendTrackerValueConverter.secondsToMilliseconds (attack)); + buffer.putShort (base + PolyendTrackerConstants.ENV_OFF_HOLD, (short) PolyendTrackerValueConverter.secondsToMilliseconds (hold)); + buffer.putShort (base + PolyendTrackerConstants.ENV_OFF_DECAY, (short) PolyendTrackerValueConverter.secondsToMilliseconds (decay)); + buffer.putFloat (base + PolyendTrackerConstants.ENV_OFF_SUSTAIN, (float) (sustainValue < 0 ? 1.0 : Math.clamp (sustainValue, 0.0, 1.0))); + buffer.putShort (base + PolyendTrackerConstants.ENV_OFF_RELEASE, (short) PolyendTrackerValueConverter.secondsToMilliseconds (release)); + buffer.put (base + PolyendTrackerConstants.ENV_OFF_LFO_FLAG, (byte) 0); + buffer.put (base + PolyendTrackerConstants.ENV_OFF_ENABLED, (byte) (enabled ? 1 : 0)); + } + + + /** + * Write the filter parameters. If the zone has no filter the filter is disabled and the cutoff + * is parked wide open. + * + * @param buffer The output buffer + * @param zone The zone + */ + private static void writeFilter (final ByteBuffer buffer, final ISampleZone zone) + { + final Optional optionalFilter = zone.getFilter (); + if (optionalFilter.isEmpty ()) + { + buffer.putFloat (PolyendTrackerConstants.OFF_CUTOFF, 1.0f); + buffer.put (PolyendTrackerConstants.OFF_FILTER_ENABLED, (byte) 0); + return; + } + + final IFilter filter = optionalFilter.get (); + final int filterType = switch (filter.getType ()) + { + case HIGH_PASS -> PolyendTrackerConstants.FILTER_HIGHPASS; + case BAND_PASS, BAND_REJECTION -> PolyendTrackerConstants.FILTER_BANDPASS; + default -> PolyendTrackerConstants.FILTER_LOWPASS; + }; + buffer.putFloat (PolyendTrackerConstants.OFF_CUTOFF, (float) PolyendTrackerValueConverter.hertzToNormalizedCutoff (filter.getCutoff ())); + buffer.putFloat (PolyendTrackerConstants.OFF_RESONANCE, (float) PolyendTrackerValueConverter.modelResonanceToRaw (filter.getResonance ())); + buffer.put (PolyendTrackerConstants.OFF_FILTER_TYPE, (byte) filterType); + buffer.put (PolyendTrackerConstants.OFF_FILTER_ENABLED, (byte) 1); + } + + + /** + * Write the audio data into the output. A stereo sample is de-interleaved into a left and a + * right block, a mono sample is copied unchanged. + * + * @param output The output array + * @param pcm The interleaved PCM data + * @param channels The number of channels + * @param frames The number of frames + */ + private static void writeAudio (final byte [] output, final byte [] pcm, final int channels, final int frames) + { + final int audioStart = PolyendTrackerConstants.AUDIO_START; + if (channels == 1) + { + System.arraycopy (pcm, 0, output, audioStart, frames * 2); + return; + } + + final int rightOffset = audioStart + frames * 2; + for (int i = 0; i < frames; i++) + { + output[audioStart + i * 2] = pcm[i * 4]; + output[audioStart + i * 2 + 1] = pcm[i * 4 + 1]; + output[rightOffset + i * 2] = pcm[i * 4 + 2]; + output[rightOffset + i * 2 + 1] = pcm[i * 4 + 3]; + } + } + + + /** + * Get the play mode for a loop type. + * + * @param loop The loop + * @return The play mode + */ + private static int loopPlaymode (final ISampleLoop loop) + { + return switch (loop.getType ()) + { + case BACKWARDS -> PolyendTrackerConstants.PLAYMODE_BACKWARD_LOOP; + case ALTERNATING -> PolyendTrackerConstants.PLAYMODE_PINGPONG_LOOP; + default -> PolyendTrackerConstants.PLAYMODE_FORWARD_LOOP; + }; + } + + + /** + * Write a null-padded ASCII string of the given length. + * + * @param buffer The output buffer + * @param offset The offset + * @param length The (fixed) number of bytes to occupy + * @param value The string value + */ + private static void writeString (final ByteBuffer buffer, final int offset, final int length, final String value) + { + final byte [] bytes = value.getBytes (StandardCharsets.US_ASCII); + final int count = Math.min (bytes.length, length - 1); + for (int i = 0; i < count; i++) + buffer.put (offset + i, bytes[i]); + } + + + /** + * Pick the sample zone to store. As a PTI file holds only one sample, the zone whose key range + * contains the default root note is preferred, otherwise the first zone is used. + * + * @param multisampleSource The multi-sample + * @return The zone or null if there is none + */ + private static ISampleZone pickRepresentativeZone (final IMultisampleSource multisampleSource) + { + ISampleZone first = null; + for (final IGroup group: multisampleSource.getGroups ()) + for (final ISampleZone zone: group.getSampleZones ()) + { + if (first == null) + first = zone; + if (zone.getKeyLow () <= PolyendTrackerConstants.DEFAULT_ROOT_NOTE && PolyendTrackerConstants.DEFAULT_ROOT_NOTE <= zone.getKeyHigh ()) + return zone; + } + return first; + } + + + /** + * Count the total number of sample zones. + * + * @param multisampleSource The multi-sample + * @return The number of zones + */ + private static int countZones (final IMultisampleSource multisampleSource) + { + int count = 0; + for (final IGroup group: multisampleSource.getGroups ()) + count += group.getSampleZones ().size (); + return count; + } +} diff --git a/src/main/java/de/mossgrabers/convertwithmoss/format/polyend/PolyendTrackerDetector.java b/src/main/java/de/mossgrabers/convertwithmoss/format/polyend/PolyendTrackerDetector.java new file mode 100644 index 00000000..5b0d0181 --- /dev/null +++ b/src/main/java/de/mossgrabers/convertwithmoss/format/polyend/PolyendTrackerDetector.java @@ -0,0 +1,361 @@ +// 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.polyend; + +import java.io.File; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import de.mossgrabers.convertwithmoss.core.IMultisampleSource; +import de.mossgrabers.convertwithmoss.core.INotifier; +import de.mossgrabers.convertwithmoss.core.detector.AbstractDetector; +import de.mossgrabers.convertwithmoss.core.detector.DefaultMultisampleSource; +import de.mossgrabers.convertwithmoss.core.model.IEnvelope; +import de.mossgrabers.convertwithmoss.core.model.IFileBasedSampleData; +import de.mossgrabers.convertwithmoss.core.model.IFilter; +import de.mossgrabers.convertwithmoss.core.model.IGroup; +import de.mossgrabers.convertwithmoss.core.model.IMetadata; +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.convertwithmoss.file.AudioFileUtils; +import de.mossgrabers.tools.FileUtils; + + +/** + * Detects recursively Polyend Tracker instrument files in folders. Files must end with .pti. + * A PTI file is a fixed size binary file which contains one 16-bit / 44.1kHz PCM sample (mono or + * stereo) plus the instrument parameters. + * + * @author Jürgen Moßgraber + */ +public class PolyendTrackerDetector extends AbstractDetector +{ + /** A filter parked wide open is sonically transparent and therefore treated as no filter. */ + private static final double TRANSPARENT_HIGH_PASS_MAX_HERTZ = 40.0; + private static final double TRANSPARENT_LOW_PASS_MIN_HERTZ = 18000.0; + + + /** + * Constructor. + * + * @param notifier The notifier + */ + public PolyendTrackerDetector (final INotifier notifier) + { + super ("Polyend Tracker", "PolyendTracker", notifier, new MetadataSettingsUI ("PolyendTracker"), ".pti"); + } + + + /** {@inheritDoc} */ + @Override + protected List readPresetFile (final File file) + { + if (this.waitForDelivery ()) + return Collections.emptyList (); + + final byte [] data; + try + { + data = Files.readAllBytes (file.toPath ()); + } + catch (final IOException ex) + { + this.notifier.logError ("IDS_NOTIFY_ERR_LOAD_FILE", ex); + return Collections.emptyList (); + } + + if (data.length < PolyendTrackerConstants.AUDIO_START + PolyendTrackerConstants.CRC_SIZE || data[0] != 'T' || data[1] != 'I') + { + this.notifier.logError ("IDS_PTI_NOT_AN_INSTRUMENT", file.getName ()); + return Collections.emptyList (); + } + + final ByteBuffer buffer = ByteBuffer.wrap (data).order (ByteOrder.LITTLE_ENDIAN); + + // Determine the number of channels and frames from the actual amount of audio data. The + // length field of the file is not always accurate, so it is only used to decide between + // mono and stereo (which differ by a factor of two). + final int audioBytes = data.length - PolyendTrackerConstants.AUDIO_START - PolyendTrackerConstants.CRC_SIZE; + final long declaredFrames = buffer.getInt (PolyendTrackerConstants.OFF_SAMPLE_LENGTH) & 0xFFFFFFFFL; + final int channels = Math.abs (audioBytes - declaredFrames * 4) < Math.abs (audioBytes - declaredFrames * 2) ? 2 : 1; + final int frames = audioBytes / (channels * 2); + if (frames <= 0) + { + this.notifier.logError ("IDS_PTI_NO_AUDIO_DATA", file.getName ()); + return Collections.emptyList (); + } + + final byte [] interleaved = deinterleaveToWav (data, PolyendTrackerConstants.AUDIO_START, frames, channels); + final DefaultAudioMetadata audioMetadata = new DefaultAudioMetadata (channels, PolyendTrackerConstants.SAMPLE_RATE, PolyendTrackerConstants.BIT_RESOLUTION, frames); + + final String name = FileUtils.getNameWithoutType (file); + final String [] parts = AudioFileUtils.createPathParts (file.getParentFile (), this.sourceFolder, name); + final IMultisampleSource multisampleSource = new DefaultMultisampleSource (file, parts, name); + + final List zones = this.createZones (buffer, audioMetadata, interleaved, frames, name); + if (zones.isEmpty ()) + { + this.notifier.logError ("IDS_PTI_NO_AUDIO_DATA", file.getName ()); + return Collections.emptyList (); + } + + final IGroup group = new DefaultGroup (zones); + multisampleSource.setGroups (new ArrayList<> (Collections.singletonList (group))); + + final IMetadata metadata = multisampleSource.getMetadata (); + this.createMetadata (metadata, (IFileBasedSampleData) null, parts); + this.updateCreationDateTime (metadata, file); + + return Collections.singletonList (multisampleSource); + } + + + /** + * Create the sample zones. If the play mode is one of the slice modes and slices are present, + * one zone is created for each slice (chromatically mapped); otherwise a single zone covering + * the whole keyboard is created. + * + * @param buffer The buffer with the file content + * @param audioMetadata The audio metadata + * @param interleaved The interleaved (WAV-ready) audio data + * @param frames The number of frames of the sample + * @param name The instrument name + * @return The zones + */ + private List createZones (final ByteBuffer buffer, final DefaultAudioMetadata audioMetadata, final byte [] interleaved, final int frames, final String name) + { + final int playmode = buffer.get (PolyendTrackerConstants.OFF_PLAYMODE) & 0xFF; + final int numSlices = buffer.get (PolyendTrackerConstants.OFF_NUM_SLICES) & 0xFF; + + final List zones = new ArrayList<> (); + final boolean sliced = (playmode == PolyendTrackerConstants.PLAYMODE_SLICE || playmode == PolyendTrackerConstants.PLAYMODE_BEAT_SLICE) && numSlices > 0; + if (sliced) + { + // Each slice becomes a self-contained sample mapped chromatically to one key, starting + // at the default root note. The audio is trimmed to the slice region so that there is no + // redundant audio data. + final int channels = audioMetadata.getChannels (); + final int bytesPerFrame = channels * 2; + for (int i = 0; i < numSlices; i++) + { + final int start = PolyendTrackerValueConverter.normalizedToFrame (buffer.getShort (PolyendTrackerConstants.OFF_SLICES + i * 2) & 0xFFFF, frames); + final int stop = i + 1 < numSlices ? PolyendTrackerValueConverter.normalizedToFrame (buffer.getShort (PolyendTrackerConstants.OFF_SLICES + (i + 1) * 2) & 0xFFFF, frames) : frames; + final int sliceFrames = stop - start; + if (sliceFrames <= 0) + continue; + + final byte [] sliceData = new byte [sliceFrames * bytesPerFrame]; + System.arraycopy (interleaved, start * bytesPerFrame, sliceData, 0, sliceData.length); + final DefaultAudioMetadata sliceMetadata = new DefaultAudioMetadata (channels, PolyendTrackerConstants.SAMPLE_RATE, PolyendTrackerConstants.BIT_RESOLUTION, sliceFrames); + + final ISampleZone zone = new DefaultSampleZone (name + " " + (i + 1), new InMemorySampleData (sliceMetadata, sliceData)); + final int note = Math.clamp (PolyendTrackerConstants.DEFAULT_ROOT_NOTE + i, 0, 127); + zone.setKeyRoot (note); + zone.setKeyLow (note); + zone.setKeyHigh (note); + zone.setStart (0); + zone.setStop (sliceFrames); + this.applyInstrumentParameters (zone, buffer); + zones.add (zone); + } + return zones; + } + + final ISampleZone zone = new DefaultSampleZone (name, new InMemorySampleData (audioMetadata, interleaved)); + zone.setKeyRoot (PolyendTrackerConstants.DEFAULT_ROOT_NOTE); + zone.setKeyLow (0); + zone.setKeyHigh (127); + zone.setStart (PolyendTrackerValueConverter.normalizedToFrame (buffer.getShort (PolyendTrackerConstants.OFF_START) & 0xFFFF, frames)); + zone.setStop (PolyendTrackerValueConverter.normalizedToFrame (buffer.getShort (PolyendTrackerConstants.OFF_END) & 0xFFFF, frames)); + + final LoopType loopType = switch (playmode) + { + case PolyendTrackerConstants.PLAYMODE_FORWARD_LOOP -> LoopType.FORWARDS; + case PolyendTrackerConstants.PLAYMODE_BACKWARD_LOOP -> LoopType.BACKWARDS; + case PolyendTrackerConstants.PLAYMODE_PINGPONG_LOOP -> LoopType.ALTERNATING; + default -> null; + }; + if (loopType != null) + { + final int loopStart = PolyendTrackerValueConverter.normalizedToFrame (buffer.getShort (PolyendTrackerConstants.OFF_LOOP1) & 0xFFFF, frames); + final int loopEnd = PolyendTrackerValueConverter.normalizedToFrame (buffer.getShort (PolyendTrackerConstants.OFF_LOOP2) & 0xFFFF, frames); + if (loopEnd > loopStart) + { + final ISampleLoop loop = new DefaultSampleLoop (); + loop.setType (loopType); + loop.setStart (loopStart); + loop.setEnd (loopEnd); + zone.addLoop (loop); + } + } + + this.applyInstrumentParameters (zone, buffer); + zones.add (zone); + return zones; + } + + + /** + * Apply the global instrument parameters (volume, panning, tuning, amplitude and pitch envelope + * and the filter) which apply to all zones of the instrument. + * + * @param zone The zone to configure + * @param buffer The buffer with the file content + */ + private void applyInstrumentParameters (final ISampleZone zone, final ByteBuffer buffer) + { + zone.setGain (PolyendTrackerValueConverter.rawVolumeToGain (buffer.get (PolyendTrackerConstants.OFF_VOLUME) & 0xFF)); + zone.setPanning (PolyendTrackerValueConverter.rawPanningToModel (buffer.getShort (PolyendTrackerConstants.OFF_PANNING))); + zone.setTuning (PolyendTrackerValueConverter.toTuning (buffer.get (PolyendTrackerConstants.OFF_TUNE), buffer.get (PolyendTrackerConstants.OFF_FINETUNE))); + + final IEnvelope amplitudeEnvelope = readEnvelope (buffer, PolyendTrackerConstants.ENV_VOLUME); + if (amplitudeEnvelope != null) + zone.getAmplitudeEnvelopeModulator ().setSource (amplitudeEnvelope); + + final IEnvelope pitchEnvelope = readEnvelope (buffer, PolyendTrackerConstants.ENV_FINETUNE); + if (pitchEnvelope != null) + { + zone.getPitchEnvelopeModulator ().setSource (pitchEnvelope); + zone.getPitchEnvelopeModulator ().setDepth (1.0); + } + + final IFilter filter = buildFilter (buffer); + if (filter != null) + zone.setFilter (filter); + } + + + /** + * Build the filter from the file parameters. + * + * @param buffer The buffer with the file content + * @return The filter or null if no (audible) filter is set + */ + private static IFilter buildFilter (final ByteBuffer buffer) + { + if ((buffer.get (PolyendTrackerConstants.OFF_FILTER_ENABLED) & 0xFF) == 0) + return null; + + final FilterType filterType = switch (buffer.get (PolyendTrackerConstants.OFF_FILTER_TYPE) & 0xFF) + { + case PolyendTrackerConstants.FILTER_LOWPASS -> FilterType.LOW_PASS; + case PolyendTrackerConstants.FILTER_HIGHPASS -> FilterType.HIGH_PASS; + case PolyendTrackerConstants.FILTER_BANDPASS -> FilterType.BAND_PASS; + default -> null; + }; + if (filterType == null) + return null; + + final double cutoff = Math.clamp ((double) buffer.getFloat (PolyendTrackerConstants.OFF_CUTOFF), 0.0, 1.0); + final double hertz = PolyendTrackerValueConverter.normalizedCutoffToHertz (cutoff); + + // A filter parked wide open is sonically transparent - treat it as no filter + if (filterType == FilterType.HIGH_PASS && hertz <= TRANSPARENT_HIGH_PASS_MAX_HERTZ) + return null; + if (filterType == FilterType.LOW_PASS && hertz >= TRANSPARENT_LOW_PASS_MIN_HERTZ) + return null; + + final double resonance = PolyendTrackerValueConverter.rawResonanceToModel (buffer.getFloat (PolyendTrackerConstants.OFF_RESONANCE)); + final IFilter filter = new DefaultFilter (filterType, 2, hertz, resonance); + + final IEnvelope cutoffEnvelope = readEnvelope (buffer, PolyendTrackerConstants.ENV_CUTOFF); + if (cutoffEnvelope != null) + { + final int base = PolyendTrackerConstants.OFF_ENVELOPES + PolyendTrackerConstants.ENV_CUTOFF * PolyendTrackerConstants.ENVELOPE_SIZE; + final double amount = Math.clamp ((double) buffer.getFloat (base + PolyendTrackerConstants.ENV_OFF_AMOUNT), 0.0, 1.0); + final double depth = PolyendTrackerValueConverter.normalizedCutoffToHertz (Math.clamp (cutoff + amount, 0.0, 1.0)) - hertz; + if (depth != 0) + { + filter.getCutoffEnvelopeModulator ().setSource (cutoffEnvelope); + filter.getCutoffEnvelopeModulator ().setDepth (depth); + } + } + + return filter; + } + + + /** + * Read an envelope. Returns null if the envelope is disabled or uses the LFO instead. + * + * @param buffer The buffer with the file content + * @param slot The index of the envelope (0 = volume, 2 = cutoff, 5 = fine tune) + * @return The envelope or null + */ + private static IEnvelope readEnvelope (final ByteBuffer buffer, final int slot) + { + final int base = PolyendTrackerConstants.OFF_ENVELOPES + slot * PolyendTrackerConstants.ENVELOPE_SIZE; + final boolean usesLFO = (buffer.get (base + PolyendTrackerConstants.ENV_OFF_LFO_FLAG) & 0xFF) != 0; + final boolean enabled = (buffer.get (base + PolyendTrackerConstants.ENV_OFF_ENABLED) & 0xFF) != 0; + if (!enabled || usesLFO) + return null; + + final int delay = buffer.getShort (base + PolyendTrackerConstants.ENV_OFF_DELAY) & 0xFFFF; + final int attack = buffer.getShort (base + PolyendTrackerConstants.ENV_OFF_ATTACK) & 0xFFFF; + final int hold = buffer.getShort (base + PolyendTrackerConstants.ENV_OFF_HOLD) & 0xFFFF; + final int decay = buffer.getShort (base + PolyendTrackerConstants.ENV_OFF_DECAY) & 0xFFFF; + final double sustain = buffer.getFloat (base + PolyendTrackerConstants.ENV_OFF_SUSTAIN); + final int release = buffer.getShort (base + PolyendTrackerConstants.ENV_OFF_RELEASE) & 0xFFFF; + + final IEnvelope envelope = new DefaultEnvelope (); + if (delay > 0) + envelope.setDelayTime (PolyendTrackerValueConverter.millisecondsToSeconds (delay)); + envelope.setAttackTime (PolyendTrackerValueConverter.millisecondsToSeconds (attack)); + if (hold > 0) + envelope.setHoldTime (PolyendTrackerValueConverter.millisecondsToSeconds (hold)); + envelope.setDecayTime (PolyendTrackerValueConverter.millisecondsToSeconds (decay)); + envelope.setSustainLevel (Math.clamp (sustain, 0.0, 1.0)); + envelope.setReleaseTime (PolyendTrackerValueConverter.millisecondsToSeconds (release)); + return envelope; + } + + + /** + * The audio data of a stereo sample is stored non-interleaved (the complete left channel + * followed by the complete right channel). Convert it to the interleaved layout expected by the + * in-memory sample data. A mono sample is returned unchanged. + * + * @param data The complete file content + * @param audioStart The offset of the audio data + * @param frames The number of frames + * @param channels The number of channels (1 or 2) + * @return The interleaved audio data + */ + private static byte [] deinterleaveToWav (final byte [] data, final int audioStart, final int frames, final int channels) + { + if (channels == 1) + { + final byte [] mono = new byte [frames * 2]; + System.arraycopy (data, audioStart, mono, 0, mono.length); + return mono; + } + + final byte [] interleaved = new byte [frames * 4]; + final int rightOffset = audioStart + frames * 2; + for (int i = 0; i < frames; i++) + { + interleaved[i * 4] = data[audioStart + i * 2]; + interleaved[i * 4 + 1] = data[audioStart + i * 2 + 1]; + interleaved[i * 4 + 2] = data[rightOffset + i * 2]; + interleaved[i * 4 + 3] = data[rightOffset + i * 2 + 1]; + } + return interleaved; + } +} diff --git a/src/main/java/de/mossgrabers/convertwithmoss/format/polyend/PolyendTrackerValueConverter.java b/src/main/java/de/mossgrabers/convertwithmoss/format/polyend/PolyendTrackerValueConverter.java new file mode 100644 index 00000000..b168bc45 --- /dev/null +++ b/src/main/java/de/mossgrabers/convertwithmoss/format/polyend/PolyendTrackerValueConverter.java @@ -0,0 +1,233 @@ +// 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.polyend; + +/** + * Converts values between the Polyend Tracker instrument format and the model representation. + * + * @author Jürgen Moßgraber + */ +public final class PolyendTrackerValueConverter +{ + /** The raw volume value which represents unity gain (0 dB). */ + private static final double VOLUME_UNITY = 50.0; + /** The raw panning value which represents the center position. */ + private static final double PANNING_CENTER = 50.0; + /** The maximum resonance value stored in the file (representing the model resonance of 1). */ + private static final double RESONANCE_MAX = 4.3; + /** The lowest filter cutoff frequency in Hertz. */ + private static final double FILTER_MIN_HERTZ = 20.0; + /** The highest filter cutoff frequency in Hertz. */ + private static final double FILTER_MAX_HERTZ = 20000.0; + + + /** + * Constructor. + */ + private PolyendTrackerValueConverter () + { + // Helper class + } + + + /** + * Convert a normalized point (0 to 65535, proportional to the sample length) into a frame + * position. + * + * @param point The normalized point + * @param totalFrames The number of frames of the sample + * @return The frame position (clamped to [0, totalFrames]) + */ + public static int normalizedToFrame (final int point, final int totalFrames) + { + if (totalFrames <= 0) + return 0; + final long frame = Math.round (point / (double) PolyendTrackerConstants.NORMALIZED_MAX * totalFrames); + return (int) Math.clamp (frame, 0, totalFrames); + } + + + /** + * Convert a frame position into a normalized point (0 to 65535, proportional to the sample + * length). + * + * @param frame The frame position + * @param totalFrames The number of frames of the sample + * @return The normalized point (clamped to [0, 65535]) + */ + public static int frameToNormalized (final int frame, final int totalFrames) + { + if (totalFrames <= 0) + return 0; + final long point = Math.round (frame / (double) totalFrames * PolyendTrackerConstants.NORMALIZED_MAX); + return (int) Math.clamp (point, 0, PolyendTrackerConstants.NORMALIZED_MAX); + } + + + /** + * Convert the raw volume (0 to 100, 50 = unity gain) into a gain in decibels. + * + * @param rawVolume The raw volume + * @return The gain in decibels + */ + public static double rawVolumeToGain (final int rawVolume) + { + final double linear = Math.clamp (rawVolume, 0, 100) / VOLUME_UNITY; + if (linear <= 0) + return 0; + return 20.0 * Math.log10 (linear); + } + + + /** + * Convert a gain in decibels into the raw volume (0 to 100, 50 = unity gain). + * + * @param gainDecibels The gain in decibels + * @return The raw volume + */ + public static int gainToRawVolume (final double gainDecibels) + { + final double linear = Math.pow (10.0, gainDecibels / 20.0); + return (int) Math.clamp (Math.round (linear * VOLUME_UNITY), 0, 100); + } + + + /** + * Convert the raw panning (0 to 100, 50 = center) into the model panning (-1 to 1). + * + * @param rawPanning The raw panning + * @return The model panning + */ + public static double rawPanningToModel (final int rawPanning) + { + return Math.clamp ((Math.clamp (rawPanning, 0, 100) - PANNING_CENTER) / VOLUME_UNITY, -1.0, 1.0); + } + + + /** + * Convert the model panning (-1 to 1) into the raw panning (0 to 100, 50 = center). + * + * @param panning The model panning + * @return The raw panning + */ + public static int modelPanningToRaw (final double panning) + { + return (int) Math.clamp (Math.round (Math.clamp (panning, -1.0, 1.0) * VOLUME_UNITY + PANNING_CENTER), 0, 100); + } + + + /** + * Convert the normalized filter cutoff (0 to 1) into a frequency in Hertz. + * + * @param cutoff The normalized cutoff + * @return The frequency in Hertz + */ + public static double normalizedCutoffToHertz (final double cutoff) + { + return FILTER_MIN_HERTZ * Math.pow (FILTER_MAX_HERTZ / FILTER_MIN_HERTZ, Math.clamp (cutoff, 0.0, 1.0)); + } + + + /** + * Convert a frequency in Hertz into the normalized filter cutoff (0 to 1). + * + * @param hertz The frequency in Hertz + * @return The normalized cutoff + */ + public static double hertzToNormalizedCutoff (final double hertz) + { + if (hertz <= FILTER_MIN_HERTZ) + return 0.0; + return Math.clamp (Math.log (hertz / FILTER_MIN_HERTZ) / Math.log (FILTER_MAX_HERTZ / FILTER_MIN_HERTZ), 0.0, 1.0); + } + + + /** + * Convert the raw resonance (0 to 4.3) into the model resonance (0 to 1). + * + * @param rawResonance The raw resonance + * @return The model resonance + */ + public static double rawResonanceToModel (final double rawResonance) + { + return Math.clamp (rawResonance / RESONANCE_MAX, 0.0, 1.0); + } + + + /** + * Convert the model resonance (0 to 1) into the raw resonance (0 to 4.3). + * + * @param resonance The model resonance + * @return The raw resonance + */ + public static double modelResonanceToRaw (final double resonance) + { + return Math.clamp (resonance, 0.0, 1.0) * RESONANCE_MAX; + } + + + /** + * Combine the coarse tune (semitones) and fine tune (cents) into a tuning value in semitones. + * + * @param tune The coarse tune in semitones + * @param finetune The fine tune in cents + * @return The tuning in semitones + */ + public static double toTuning (final int tune, final int finetune) + { + return tune + finetune / 100.0; + } + + + /** + * Get the coarse tune (semitones) part of a tuning value. + * + * @param tuning The tuning in semitones + * @return The coarse tune in semitones (clamped to [-24, 24]) + */ + public static int tuningToTune (final double tuning) + { + return (int) Math.clamp (Math.round (tuning), -24, 24); + } + + + /** + * Get the fine tune (cents) part of a tuning value. + * + * @param tuning The tuning in semitones + * @return The fine tune in cents (clamped to [-100, 100]) + */ + public static int tuningToFinetune (final double tuning) + { + final double fraction = tuning - tuningToTune (tuning); + return (int) Math.clamp (Math.round (fraction * 100.0), -100, 100); + } + + + /** + * Convert milliseconds into seconds. + * + * @param milliseconds The milliseconds + * @return The seconds + */ + public static double millisecondsToSeconds (final int milliseconds) + { + return milliseconds / 1000.0; + } + + + /** + * Convert seconds into milliseconds (clamped to the unsigned 16-bit range). + * + * @param seconds The seconds (a negative value is treated as 0) + * @return The milliseconds + */ + public static int secondsToMilliseconds (final double seconds) + { + if (seconds <= 0) + return 0; + return (int) Math.clamp (Math.round (seconds * 1000.0), 0, PolyendTrackerConstants.NORMALIZED_MAX); + } +} diff --git a/src/main/resources/Strings.properties b/src/main/resources/Strings.properties index d1184cf0..53ac9435 100644 --- a/src/main/resources/Strings.properties +++ b/src/main/resources/Strings.properties @@ -149,7 +149,6 @@ IDS_AKM_VERSION=Detected Akai Multi (%2 - %1).\n IDS_BLISS_DETECTED_PROGRAM=Detected program '%1' stored with version %2.\n IDS_BLISS_LIMITED_PROGRAM_TO_128=Limited source programs to 128 which is the maximum of a bank. -IDS_RENOISE_FLAC_FALLBACK=The FLAC encoder failed for sample '%1', it is stored as WAV instead.\n IDS_DEX_UNKNOWN_FILE_VERSION=Unknown file version: %1\n IDS_DEX_UNKNOWN_PRESET_VERSION=Unknown preset version %1. Conversion might not be correct.\n @@ -336,6 +335,13 @@ IDS_OMNISPHERE_NO_LOOKING_UP_SOUND_SOURCE=Looking up sound source: %1\n IDS_OMNISPHERE_SOUND_SOURCE_NOT_FOUND=Could not find sound source: %1\n IDS_OMNISPHERE_ERR_READING_WAV=Could not read WAV file '%1' (%2). +IDS_PTI_NOT_AN_INSTRUMENT=The file '%1' is not a Polyend Tracker instrument.\n +IDS_PTI_NO_AUDIO_DATA=The Polyend Tracker instrument '%1' does not contain any audio data.\n +IDS_PTI_ONLY_ONE_SAMPLE=A Polyend Tracker instrument holds only one sample. Storing the sample '%1', all others are ignored.\n +IDS_PTI_UNSUPPORTED_CHANNELS=Polyend Tracker supports mono or stereo samples only but found %1 channels.\n + +IDS_RENOISE_FLAC_FALLBACK=The FLAC encoder failed for sample '%1', it is stored as WAV instead.\n + IDS_QPAT_UNKNOWN_TYPE=Not a Waldorf Quantum patch file.\n IDS_QPAT_VERSION=Detected preset version %1, written on %2. %3.\n IDS_QPAT_UNKNOWN_RESOURCE_TYPE=Unknown resource type: %1.\n