From a716cb737f924be7e67ee1ba61ee4851f88271c1 Mon Sep 17 00:00:00 2001 From: Douglas Carmichael Date: Mon, 29 Jun 2026 13:51:02 -0400 Subject: [PATCH] Add opt-in "snap loops to zero-crossings" processing option Some sample libraries ship forward loops whose start and end land on non-matching sample values, so the loop audibly clicks at the wrap-around on every repeat (common with auto-sampled instruments whose loop was not designed to be click-free). This adds a processing option that moves both loop boundaries to a nearby zero-crossing so the loop end and start meet near zero, removing the click without resampling the audio - only the stored loop positions change. The adjustment is conservative: it only touches forward loops, leaves very short (single-cycle) loops untouched so their pitch is unchanged, moves a boundary by at most an eighth of the loop length (capped at 512 frames), and only applies when it actually reduces the discontinuity at the wrap. A loop end of -1 ("loop to sample end") is materialised to an explicit zero-crossing when snapping helps. Enabled with the "Snap loops to zero-crossings" check-box in the processing dialog or -Zs on the command line; off by default. --- documentation/CHANGELOG.md | 1 + .../convertwithmoss/core/CLIBackend.java | 2 + .../core/ConverterBackend.java | 10 + .../convertwithmoss/core/DetectSettings.java | 4 +- .../core/algorithm/LoopZeroSnapper.java | 241 ++++++++++++++++++ .../convertwithmoss/ui/MainFrame.java | 5 + .../convertwithmoss/ui/ProcessingDialog.java | 4 + src/main/resources/Strings.properties | 3 + 8 files changed, 269 insertions(+), 1 deletion(-) create mode 100644 src/main/java/de/mossgrabers/convertwithmoss/core/algorithm/LoopZeroSnapper.java diff --git a/documentation/CHANGELOG.md b/documentation/CHANGELOG.md index 935e021d..11f31f1e 100644 --- a/documentation/CHANGELOG.md +++ b/documentation/CHANGELOG.md @@ -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 ``/`` tag; the root tag is now matched with or without attributes. diff --git a/src/main/java/de/mossgrabers/convertwithmoss/core/CLIBackend.java b/src/main/java/de/mossgrabers/convertwithmoss/core/CLIBackend.java index 2b7e2851..998c1b10 100644 --- a/src/main/java/de/mossgrabers/convertwithmoss/core/CLIBackend.java +++ b/src/main/java/de/mossgrabers/convertwithmoss/core/CLIBackend.java @@ -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 ()); @@ -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); diff --git a/src/main/java/de/mossgrabers/convertwithmoss/core/ConverterBackend.java b/src/main/java/de/mossgrabers/convertwithmoss/core/ConverterBackend.java index 856cdb8b..6102c774 100644 --- a/src/main/java/de/mossgrabers/convertwithmoss/core/ConverterBackend.java +++ b/src/main/java/de/mossgrabers/convertwithmoss/core/ConverterBackend.java @@ -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; @@ -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) { diff --git a/src/main/java/de/mossgrabers/convertwithmoss/core/DetectSettings.java b/src/main/java/de/mossgrabers/convertwithmoss/core/DetectSettings.java index 01e4fecc..cae620c4 100644 --- a/src/main/java/de/mossgrabers/convertwithmoss/core/DetectSettings.java +++ b/src/main/java/de/mossgrabers/convertwithmoss/core/DetectSettings.java @@ -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; /** @@ -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); } } diff --git a/src/main/java/de/mossgrabers/convertwithmoss/core/algorithm/LoopZeroSnapper.java b/src/main/java/de/mossgrabers/convertwithmoss/core/algorithm/LoopZeroSnapper.java new file mode 100644 index 00000000..99e7dff5 --- /dev/null +++ b/src/main/java/de/mossgrabers/convertwithmoss/core/algorithm/LoopZeroSnapper.java @@ -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. + *

+ * 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 sampleZones) + { + int adjusted = 0; + for (final ISampleZone zone: sampleZones) + { + final List 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; + } +} diff --git a/src/main/java/de/mossgrabers/convertwithmoss/ui/MainFrame.java b/src/main/java/de/mossgrabers/convertwithmoss/ui/MainFrame.java index 31634dfe..8e33c02c 100644 --- a/src/main/java/de/mossgrabers/convertwithmoss/ui/MainFrame.java +++ b/src/main/java/de/mossgrabers/convertwithmoss/ui/MainFrame.java @@ -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; @@ -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 // @@ -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 @@ -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 ()) { @@ -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 (); } } diff --git a/src/main/java/de/mossgrabers/convertwithmoss/ui/ProcessingDialog.java b/src/main/java/de/mossgrabers/convertwithmoss/ui/ProcessingDialog.java index b14364e5..dd7ea840 100644 --- a/src/main/java/de/mossgrabers/convertwithmoss/ui/ProcessingDialog.java +++ b/src/main/java/de/mossgrabers/convertwithmoss/ui/ProcessingDialog.java @@ -62,6 +62,8 @@ public class ProcessingDialog extends AbstractDialog public CheckBox alwaysResampleCheckbox; /** Combo-box for the loop cross-fades. */ public ComboBox loopCrossfadesCombobox; + /** Check-box to snap forward loop boundaries to zero-crossings. */ + public CheckBox snapLoopsCheckbox; /** @@ -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"); @@ -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 ()); diff --git a/src/main/resources/Strings.properties b/src/main/resources/Strings.properties index 1a207681..4ab640c7 100644 --- a/src/main/resources/Strings.properties +++ b/src/main/resources/Strings.properties @@ -126,6 +126,7 @@ IDS_PROCESSING_REDUCE_FREQUENCY_TO=Reduce Frequency to %1... IDS_PROCESSING_ALWAYS_RESAMPLE=Always re-sample... IDS_PROCESSING_REDUCE_BIT_DEPTH_NOT_SUPPORTED=The selected processing bit-depth (%1 bit) is not supported by the selected destination format (only %2). IDS_PROCESSING_LOOP_CROSSFADE_LOG=Fix loop cross-fade... +IDS_PROCESSING_SNAP_LOOPS=Snap loops to zero-crossings (%1 adjusted)... IDS_CLI_UNKNOWN_SOURCE_FORMAT=Invalid value for source format: %1\nAllowed values are: %2\n IDS_CLI_UNKNOWN_DESTINATION_FORMAT=Invalid value for destination format: %1\nAllowed values are: %2\n @@ -458,6 +459,8 @@ IDS_PROCESSING_ALWAYS_RESAMPLE_TOOLTIP=Does as well up-sampling to the set sampl IDS_PROCESSING_LOOPS=Loops IDS_PROCESSING_LOOP_CROSSFADE=Set fixed loop-crossfade: IDS_PROCESSING_LOOP_CROSSFADE_TOOLTIP=Sets all loop cross-fades (if supported by the destination) to this percentage value. +IDS_PROCESSING_SNAP_LOOPS_LABEL=Snap loops to zero-crossings +IDS_PROCESSING_SNAP_LOOPS_TOOLTIP=Moves the start and end of forward loops to a nearby zero-crossing to remove the click at the loop point. Single-cycle loops are left untouched. IDS_MAIN_SEARCH_FORMAT=Search format\u2026 IDS_MAIN_CONVERT=Con_vert