Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions documentation/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
* New: Added support for the Synthstrom Deluge instrument format (thanks to Douglas Carmichael).
* New: Added support for the Downloadable Sound format (DLS) - read only.
* New: Added several new tags for category detection.
* New: Added an opt-in *Snap loops to zero-crossings* processing option. It moves the start and end of forward loops to a nearby zero-crossing, which removes the click that some sample libraries have at the loop point (e.g. auto-sampled instruments whose loop was not designed to be click-free). The adjustment is conservative: single-cycle loops are left untouched and a boundary is only moved when it actually reduces the discontinuity at the loop wrap. Enabled with the *Snap loops to zero-crossings* check-box in the processing dialog or `-Zs` on the command line (thanks to Douglas Carmichael).
* Fixed: Converting into the root of a USB stick or external drive could fail with "The output folder is not empty" even when it looked empty, because the operating system keeps hidden bookkeeping files there (e.g. .Spotlight-V100, .Trashes and .fseventsd on macOS). Hidden files/folders and the known Windows system folders are now ignored by the empty-folder check (thanks to Douglas Carmichael).
* Synthstrom Deluge (thanks to Douglas Carmichael)
* Fixed: Sample-based SOUND and KIT files were rejected with "This is not a Deluge KIT or SOUND file" when the root tag carried attributes (e.g. `firmwareVersion` written inline by firmware 3.x). The broken-XML workaround only matched the bare `<sound>`/`<kit>` tag; the root tag is now matched with or without attributes.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ public void parseCommandLine (final String [] arguments)
spec.addOption (OptionSpec.builder ("-Zf", "--ProcessFrequency").paramLabel ("PROCESS_FREQUENCY").type (Integer.class).description ("Reduces the sample-rate of all samples to this maximum value, if processing is enabled. Valid numbers are: 48000, 44100, 32000, 31250, 30000, 28000, 27000, 24000, 22050, 16000, 12000, 11025 and 8000").build ());
spec.addOption (OptionSpec.builder ("-Za", "--ProcessAlwaysResample").paramLabel ("PROCESS_ALWAYS_RESAMPLE").type (Boolean.class).description ("Does as well up-sampling to the set sample frequency and bit depth, if enabled.").build ());
spec.addOption (OptionSpec.builder ("-Zl", "--ProcessLoopCrossfade").paramLabel ("PROCESS_LOOP_CROSSFADE").type (Integer.class).description ("Sets a fixed loop crossfade as a percentage. Valid values are 0-100.").build ());
spec.addOption (OptionSpec.builder ("-Zs", "--ProcessSnapLoops").paramLabel ("PROCESS_SNAP_LOOPS").type (Boolean.class).description ("Snaps forward loop boundaries to the nearest zero-crossing to remove loop clicks, if processing is enabled.").build ());

spec.addPositional (PositionalParamSpec.builder ().paramLabel ("SOURCE_FOLDER").type (File.class).description ("The source folder to process.").required (true).build ());
spec.addPositional (PositionalParamSpec.builder ().paramLabel ("DESTINATION_FOLDER").type (File.class).description ("The destination folder to write to.").required (true).build ());
Expand Down Expand Up @@ -192,6 +193,7 @@ private int run (final ParseResult parseResult)
}
detectSettings.alwaysResample = parseResult.matchedOptionValue ("Za", Boolean.FALSE).booleanValue ();
detectSettings.loopCrossfades = parseResult.matchedOptionValue ("Zl", Integer.valueOf (-1)).intValue () + 1;
detectSettings.snapLoopsToZero = parseResult.matchedOptionValue ("Zs", Boolean.FALSE).booleanValue ();

// Renaming option & folder check
detectSettings.sourceFolder = parseResult.matchedPositionalValue (0, null);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import javax.sound.sampled.UnsupportedAudioFileException;

import de.mossgrabers.convertwithmoss.core.algorithm.AudioSampleReducer;
import de.mossgrabers.convertwithmoss.core.algorithm.LoopZeroSnapper;
import de.mossgrabers.convertwithmoss.core.algorithm.MultiSampleReducer;
import de.mossgrabers.convertwithmoss.core.creator.AbstractCreator;
import de.mossgrabers.convertwithmoss.core.creator.ICreator;
Expand Down Expand Up @@ -455,6 +456,15 @@ private void processSamples (final IMultisampleSource multisampleSource)
this.notifier.log ("IDS_PROCESSING_NORMALIZING");
this.notifier.log ("IDS_NOTIFY_LINE_FEED");
AudioSampleReducer.reduceSamples (sampleZones, this.detectionSettings.enableMakeMono, this.detectionSettings.enableTrimSample, this.detectionSettings.reduceBitDepth, this.detectionSettings.reduceFrequency, this.detectionSettings.alwaysResample, this.detectionSettings.enableNormalize);

///////////////////////////////////////////////////////
// Snap forward loop boundaries to zero-crossings to remove loop clicks

if (this.detectionSettings.snapLoopsToZero)
{
final int snapped = LoopZeroSnapper.snap (sampleZones);
this.notifier.log ("IDS_PROCESSING_SNAP_LOOPS", Integer.toString (snapped));
}
}
catch (final IOException | UnsupportedAudioFileException ex)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ public class DetectSettings
public boolean alwaysResample = false;
/** The fixed loop cross-fade. 0 is off. */
public int loopCrossfades = 0;
/** Snap forward loop boundaries to the nearest zero-crossing to avoid loop clicks. */
public boolean snapLoopsToZero = false;


/**
Expand All @@ -54,6 +56,6 @@ public class DetectSettings
*/
public boolean needsProcessing ()
{
return this.enableProcessing && (this.maxNumberOfSamples > 0 || this.enableMakeMono || this.enableTrimSample || this.reduceBitDepth > 0 || this.reduceFrequency > 0 || this.enableNormalize || this.loopCrossfades > 0);
return this.enableProcessing && (this.maxNumberOfSamples > 0 || this.enableMakeMono || this.enableTrimSample || this.reduceBitDepth > 0 || this.reduceFrequency > 0 || this.enableNormalize || this.loopCrossfades > 0 || this.snapLoopsToZero);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
// 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.core.algorithm;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.List;

import javax.sound.sampled.AudioFormat;
import javax.sound.sampled.AudioInputStream;
import javax.sound.sampled.AudioSystem;
import javax.sound.sampled.UnsupportedAudioFileException;

import de.mossgrabers.convertwithmoss.core.model.ISampleLoop;
import de.mossgrabers.convertwithmoss.core.model.ISampleZone;
import de.mossgrabers.convertwithmoss.core.model.enumeration.LoopType;


/**
* Snaps the start and end of forward loops to a nearby zero-crossing of the sample audio. Sample
* libraries sometimes ship loops whose boundaries land on non-matching sample values, so the loop
* audibly clicks at the wrap-around point on every repeat. Moving both boundaries to a rising
* zero-crossing makes the loop end and the loop start meet near zero, which removes the click
* without altering the audio - only the stored loop positions change.
* <p>
* The adjustment is conservative: it is only applied when it actually reduces the discontinuity at
* the loop wrap, very short (single-cycle) loops are left untouched so their pitch is not changed,
* and a boundary is moved by at most an eighth of the loop length.
*
* @author Jürgen Moßgraber
*/
public final class LoopZeroSnapper
{
/** Loops shorter than this many frames are not touched (single-cycle / pitch critical). */
private static final int MINIMUM_LOOP_LENGTH = 4096;
/** The maximum number of frames a loop boundary may be moved to find a zero-crossing. */
private static final int MAXIMUM_WINDOW = 512;


/**
* Constructor.
*/
private LoopZeroSnapper ()
{
// Intentionally empty
}


/**
* Snap the forward loops of all given zones to a nearby zero-crossing.
*
* @param sampleZones The zones whose loops to adjust
* @return The number of loops which were adjusted
*/
public static int snap (final List<ISampleZone> sampleZones)
{
int adjusted = 0;
for (final ISampleZone zone: sampleZones)
{
final List<ISampleLoop> loops = zone.getLoops ();
if (loops.isEmpty ())
continue;

final int [] signal;
try
{
signal = readMonoSignal (zone);
}
catch (final IOException | UnsupportedAudioFileException _)
{
// The audio cannot be read - leave the loop unchanged
continue;
}
if (signal.length < MINIMUM_LOOP_LENGTH)
continue;

for (final ISampleLoop loop: loops)
if (snapLoop (loop, signal))
adjusted++;
}
return adjusted;
}


/**
* Snap a single loop if it is a forward loop and a better (lower discontinuity) boundary pair
* can be found nearby.
*
* @param loop The loop to adjust
* @param signal The mono mix of the sample audio
* @return True if the loop was adjusted
*/
private static boolean snapLoop (final ISampleLoop loop, final int [] signal)
{
if (loop.getType () != LoopType.FORWARDS)
return false;

final int length = signal.length;
final int start = loop.getStart ();
// A loop end of -1 (or beyond the audio) means "loop to the end of the sample"
int end = loop.getEnd ();
if (end < 0 || end >= length)
end = length - 1;
if (start < 0 || end <= start)
return false;
if (end - start < MINIMUM_LOOP_LENGTH)
return false;

final int window = Math.min (MAXIMUM_WINDOW, (end - start) / 8);
if (window < 1)
return false;
final int newStart = nearestRisingZeroCrossing (signal, start, window);
final int newEnd = nearestRisingZeroCrossing (signal, end, window);
if (newStart < 0 || newEnd < 0 || newEnd <= newStart)
return false;

// Only apply the snap when it actually reduces the click at the wrap-around
if (discontinuity (signal, newStart, newEnd) >= discontinuity (signal, start, end))
return false;

loop.setStart (newStart);
loop.setEnd (newEnd);
return true;
}


/**
* The size of the jump at the loop wrap-around - the absolute difference between the last
* played frame of the loop and its first frame. The loop end is inclusive, so it is the last
* frame which is played before the loop jumps back to its start.
*
* @param signal The mono mix of the sample audio
* @param start The loop start frame
* @param end The loop end frame (inclusive)
* @return The absolute sample-value difference at the wrap
*/
private static int discontinuity (final int [] signal, final int start, final int end)
{
final int last = Math.max (0, Math.min (end, signal.length - 1));
final int first = Math.max (0, Math.min (start, signal.length - 1));
return Math.abs (signal[last] - signal[first]);
}


/**
* Find the frame of the rising zero-crossing (a non-positive sample followed by a positive one)
* which is closest to the given position, within the given window.
*
* @param signal The mono mix of the sample audio
* @param position The position to search around
* @param window The maximum distance to search in both directions
* @return The frame index of the crossing or -1 if none was found
*/
private static int nearestRisingZeroCrossing (final int [] signal, final int position, final int window)
{
for (int distance = 0; distance <= window; distance++)
{
final int after = position + distance;
if (after > 0 && after < signal.length && signal[after - 1] <= 0 && signal[after] > 0)
return after;
final int before = position - distance;
if (before > 0 && before < signal.length && signal[before - 1] <= 0 && signal[before] > 0)
return before;
}
return -1;
}


/**
* Decode the sample audio of a zone into a mono (channel sum) integer signal.
*
* @param zone The zone
* @return The mono signal (one integer per frame), empty if the bit depth is unsupported
* @throws IOException Could not read the audio
* @throws UnsupportedAudioFileException The audio format is not supported
*/
private static int [] readMonoSignal (final ISampleZone zone) throws IOException, UnsupportedAudioFileException
{
final ByteArrayOutputStream out = new ByteArrayOutputStream ();
zone.getSampleData ().writeSample (out);
try (final AudioInputStream audioInputStream = AudioSystem.getAudioInputStream (new ByteArrayInputStream (out.toByteArray ())))
{
final AudioFormat format = audioInputStream.getFormat ();
final int channels = Math.max (1, format.getChannels ());
final int bits = format.getSampleSizeInBits ();
if (bits != 8 && bits != 16 && bits != 24 && bits != 32)
return new int [0];

final boolean bigEndian = format.isBigEndian ();
final int bytesPerSample = bits / 8;
final int frameSize = bytesPerSample * channels;
final byte [] data = audioInputStream.readAllBytes ();
final int numberOfFrames = frameSize == 0 ? 0 : data.length / frameSize;
final int [] signal = new int [numberOfFrames];
for (int frame = 0; frame < numberOfFrames; frame++)
{
final int frameOffset = frame * frameSize;
long sum = 0;
for (int channel = 0; channel < channels; channel++)
sum += readSample (data, frameOffset + channel * bytesPerSample, bits, bigEndian);
signal[frame] = (int) (sum / channels);
}
return signal;
}
}


/**
* Read a single signed PCM sample from a byte array.
*
* @param data The audio data
* @param offset The byte offset of the sample
* @param bits The number of bits per sample (8, 16, 24 or 32)
* @param bigEndian True if the data is stored big-endian
* @return The signed sample value
*/
private static int readSample (final byte [] data, final int offset, final int bits, final boolean bigEndian)
{
final int bytes = bits / 8;
if (bytes <= 0 || offset + bytes > data.length)
return 0;
// 8-bit WAV samples are unsigned with a bias of 128
if (bits == 8)
return (data[offset] & 0xFF) - 128;

int sample = 0;
if (bigEndian)
for (int b = 0; b < bytes; b++)
sample = (sample << 8) | (data[offset + b] & 0xFF);
else
for (int b = bytes - 1; b >= 0; b--)
sample = (sample << 8) | (data[offset + b] & 0xFF);

// Sign-extend to a full integer
final int shift = 32 - bits;
return sample << shift >> shift;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ public class MainFrame extends AbstractFrame implements INotifier
private static final String PROCESSING_REDUCE_FREQUENCY = "ProcessingReduceFrequency";
private static final String PROCESSING_ALWAYS_RESAMPLE = "ProcessingAlwaysResample";
private static final String PROCESSING_LOOP_CROSSFADES = "ProcessingLoopCrossfades";
private static final String PROCESSING_SNAP_LOOPS = "ProcessingSnapLoops";

private static final int DEST_TYPE_PRESET = 0;
private static final int DEST_TYPE_PRESET_LIBRARY = 1;
Expand Down Expand Up @@ -454,6 +455,7 @@ private void loadConfiguration ()
this.detectSettings.reduceFrequency = this.config.getInteger (PROCESSING_REDUCE_FREQUENCY, 0);
this.detectSettings.alwaysResample = this.config.getBoolean (PROCESSING_ALWAYS_RESAMPLE, false);
this.detectSettings.loopCrossfades = this.config.getInteger (PROCESSING_LOOP_CROSSFADES, 0);
this.detectSettings.snapLoopsToZero = this.config.getBoolean (PROCESSING_SNAP_LOOPS, false);

// Options
//
Expand Down Expand Up @@ -507,6 +509,7 @@ private void saveConfiguration ()
this.config.setInteger (PROCESSING_REDUCE_FREQUENCY, this.detectSettings.reduceFrequency);
this.config.setBoolean (PROCESSING_ALWAYS_RESAMPLE, this.detectSettings.alwaysResample);
this.config.setInteger (PROCESSING_LOOP_CROSSFADES, this.detectSettings.loopCrossfades);
this.config.setBoolean (PROCESSING_SNAP_LOOPS, this.detectSettings.snapLoopsToZero);

//
// Options
Expand Down Expand Up @@ -572,6 +575,7 @@ private void openProcessing ()
this.processingDialog.selectFrequency (this.detectSettings.reduceFrequency);
this.processingDialog.alwaysResampleCheckbox.setSelected (this.detectSettings.alwaysResample);
this.processingDialog.selectLoopCrossfades (this.detectSettings.loopCrossfades);
this.processingDialog.snapLoopsCheckbox.setSelected (this.detectSettings.snapLoopsToZero);

if (this.processingDialog.display ())
{
Expand All @@ -585,6 +589,7 @@ private void openProcessing ()
this.detectSettings.reduceFrequency = this.processingDialog.getFrequency ();
this.detectSettings.alwaysResample = this.processingDialog.alwaysResampleCheckbox.isSelected ();
this.detectSettings.loopCrossfades = this.processingDialog.getLoopCrossfades ();
this.detectSettings.snapLoopsToZero = this.processingDialog.snapLoopsCheckbox.isSelected ();
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ public class ProcessingDialog extends AbstractDialog
public CheckBox alwaysResampleCheckbox;
/** Combo-box for the loop cross-fades. */
public ComboBox<String> loopCrossfadesCombobox;
/** Check-box to snap forward loop boundaries to zero-crossings. */
public CheckBox snapLoopsCheckbox;


/**
Expand Down Expand Up @@ -224,6 +226,7 @@ protected Pane init ()

final BoxPanel panel4 = new TwoColsPanel ();
this.loopCrossfadesCombobox = panel4.createComboBox ("@IDS_PROCESSING_LOOP_CROSSFADE", "@IDS_PROCESSING_LOOP_CROSSFADE_TOOLTIP", LOOP_CROSSFADES);
this.snapLoopsCheckbox = panel4.createCheckBox ("@IDS_PROCESSING_SNAP_LOOPS_LABEL", "@IDS_PROCESSING_SNAP_LOOPS_TOOLTIP");

final BoxPanel panel = new BoxPanel (Orientation.VERTICAL);
this.enableProcessingCheckbox = panel.createCheckBox ("@IDS_PROCESSING_ENABLE", "@IDS_PROCESSING_ENABLE_TOOLTIP");
Expand All @@ -241,6 +244,7 @@ protected Pane init ()
this.traversalManager.add (this.reduceFrequencyCombobox);
this.traversalManager.add (this.alwaysResampleCheckbox);
this.traversalManager.add (this.loopCrossfadesCombobox);
this.traversalManager.add (this.snapLoopsCheckbox);
this.traversalManager.add (this.getOKButton ());
this.traversalManager.add (this.getCancelButton ());

Expand Down
Loading