From 35c54c09deab54d306e519b1a992b7be2b26962a Mon Sep 17 00:00:00 2001 From: Douglas Carmichael Date: Thu, 13 Aug 2026 10:23:21 -0400 Subject: [PATCH] Add the analysis details log The Analyse run can log for every found multi-sample what it contains: the mapping of its zones with their sample format, loops with their cross-fades, envelopes, LFOs and the filter with its modulators. Only attributes which a source actually uses are logged, so searching the log finds the sources which use a specific feature - e.g. all presets of a library with a filter envelope, a loop cross-fade or a release trigger. Enabled with the option 'Log analysis details' in the Settings dialog or with -ad on the command line. The details describe the source as it was read, before any processing is applied. The attribute names are deliberately not translated, so a search pattern does not depend on the language of the user interface. --- documentation/CHANGELOG.md | 1 + documentation/README.md | 13 +- .../convertwithmoss/core/AnalysisLogger.java | 491 ++++++++++++++++++ .../convertwithmoss/core/CLIBackend.java | 4 +- .../core/ConverterBackend.java | 10 + .../convertwithmoss/core/DetectSettings.java | 2 + .../convertwithmoss/ui/MainFrame.java | 5 + .../convertwithmoss/ui/SettingsDialog.java | 4 + src/main/resources/Strings.properties | 3 + 9 files changed, 530 insertions(+), 3 deletions(-) create mode 100644 src/main/java/de/mossgrabers/convertwithmoss/core/AnalysisLogger.java diff --git a/documentation/CHANGELOG.md b/documentation/CHANGELOG.md index 85d86db7..bb1fe3dc 100644 --- a/documentation/CHANGELOG.md +++ b/documentation/CHANGELOG.md @@ -4,6 +4,7 @@ * New: Added support for the E-mu Emulator II (IMG, EMUIIFD, HFE). One disk becomes one multi-sample: the key map of the bank gives the zones with their key ranges and root keys and each voice its name, its loop and its audio, which is expanded from the companded bytes the sampler feeds to its AM6072 DAC. A bank which is larger than one floppy is reported as continuing on another disk. Read only. * New: A note is logged when a loop of a converted preset audibly clicks at its wrap-around point (the step back to the loop start is many times the normal movement of the waveform and a substantial part of the level). Sample libraries ship such loops surprisingly often - 17 of 152 presets across three commercial Ensoniq libraries - and without the note the first hint is the converted preset ticking on the destination device. Nothing is changed: the loop is written as the source authored it, and the note points to the snap-to-zero-crossing and loop cross-fade processing options which remove such clicks. A loop which already has a cross-fade is not reported. +* New: The Analyse run can log for every found multi-sample what it contains (option "Log analysis details", on the command line `-ad`): the mapping of its zones with their sample format, loops with their cross-fades, envelopes, LFOs and the filter with its modulators. Only attributes which a source actually uses are logged, so searching the log finds the sources which use a specific feature - e.g. all presets of a library with a filter envelope, a loop cross-fade or a release trigger. The details describe the source as it was read, before any processing is applied. * Fixed: The sample and program files which a preset references are now looked up ignoring the upper/lower case of their name. Sampler file systems are case-insensitive and their CD-ROMs store all names in upper case, but a preset might well reference them in lower case. On a case-sensitive file system, which is the normal case on Linux, none of those samples could be found (e.g. 'STR SEC.6 -L.WAV' referenced as 'STR SEC.6 -L.wav'). This affects the Akai S5000/S6000 (AKP/AKM), Akai MESA, Akai MPC and MPC1000, FL Studio DirectWave, Teenage Engineering OP-XY and Synclavier Regen formats as well as all formats which use the common sample search (Ableton, Deluge, EXS24, NI Kontakt, NI Maschine, 1010music, SXT and TX16Wx) - the latter did only try the file ending in all upper and all lower case, so a name which differs in its case anywhere else was still not found. * Fixed: When a sample was not found next to its preset, the search jumped the configured number of folder levels up in one go and took whatever the recursive search found first. Two libraries below the same folder which name their samples alike - the normal case for the variants of a library - then fed a preset the sample of the other library, which sounds wrong and, since the preset keeps its own loop points, clicks at the loop. The search now goes up one level at a time and stops at the first level which contains the file, so a sample close to the preset always wins over one of the same name further away. * Fixed: The category names 'Winds' and 'World' were not detected when they were read back from a preset which stores the category as text: 'Winds' was detected as FX - from the 'Wind' sound effect - and 'World' was not detected at all. The name of a category is now a keyword of its own, which is checked for all of them. diff --git a/documentation/README.md b/documentation/README.md index bdf92012..d16ae898 100644 --- a/documentation/README.md +++ b/documentation/README.md @@ -102,7 +102,7 @@ For Linux (BSD not tested) there is also a `Makefile` for build and install with 6. Choose the type of the created output format: either single presets, a preset library which contains all found source files, a performance which contains several presets with settings or finally a library of performances. Only some destination formats support libraries and performances, all others are greyed out. 7. Press the *Convert* button to start the conversion. The progress is shown with notification messages in the log area, which you should check for potential errors like defect source files, skipped folder, etc. This log is also written to a file in the output folder. -Alternatively, press *Analyse* to analyse all potential source files but do not to write any file(s). Use this to quickly check for errors before finally running the slower conversion process. +Alternatively, press *Analyse* to analyse all potential source files but do not to write any file(s). Use this to quickly check for errors before finally running the slower conversion process. If the option *Log analysis details* is enabled, the run additionally logs for every found multi-sample what it contains (see the Options section below). ## Picking specific source presets (Contents...) @@ -132,6 +132,7 @@ Clicking the *Process* button open a dialog to configure processing options whic * **Create folder structure**: If enabled, sub-folders from the source folder are created as well in the output folder. For example, if I select my whole "Sounds" folder, there are sub-folders like `Sounds/07 Synth/Lead/01W Emerson'70 Samples`. In that case the output folder would contain e.g. `07 Synth/Lead/01W Emerson'70.multisample` if Bitwig multisample is selected as the destination format. * **Add new files**: Starts the conversion even if the output folder is not empty. Duplicates will get unique names by adding numbers. +* **Log analysis details**: If enabled, the *Analyse* run logs for every found multi-sample what it contains: the mapping of its zones (key/velocity ranges, root note, sample rate, bit resolution, channels, length), loops with their cross-fades, envelopes, LFOs and the filter with its modulators. Only attributes which a source actually uses are logged, so searching the log finds the sources which use a specific feature - e.g. all presets of a library with a filter envelope or a loop cross-fade. The details describe the source as it was read, before any processing is applied. On the command line use `-ad` instead of `-a`. The attribute names are deliberately not translated, so search patterns do not depend on the language of the user interface. * **Dark Mode**: Toggles the user interface between a light and dark layout. [1]: https://github.com/git-moss/ConvertWithMoss/blob/main/documentation/SupportedFeaturesSampleFormats.ods @@ -149,7 +150,7 @@ First display all of the available attributes by typing: The following output is displayed (the processing parameters are omitted): ``` -Usage: ConvertWithMoss [-afhPV] -d=DESTINATION [-l=LIBRARY] +Usage: ConvertWithMoss [-afhPV] [-ad] -d=DESTINATION [-l=LIBRARY] -s=SOURCE [-t=TYPE] [-p[=KEY=VALUE...]]... SOURCE... DESTINATION_FOLDER SOURCE... DESTINATION_FOLDER @@ -158,6 +159,14 @@ Usage: ConvertWithMoss [-afhPV] -d=DESTINATION [-l=LIBRARY] using a wildcard), followed by the destination folder to write to. -a, --analyze If present, only analyzes the potential source files. + -ad, --analyze-details + Like --analyze, but additionally logs for every found + multi-sample what it contains: the mapping of its + zones, the sample format, loops, envelopes, LFOs + and the filter. Only attributes which a source + actually uses are logged, so searching the output + finds the source files which use a specific + feature. -d, --destination=DESTINATION The destination format. -f, --flat If present, the folder structure is not recreated in diff --git a/src/main/java/de/mossgrabers/convertwithmoss/core/AnalysisLogger.java b/src/main/java/de/mossgrabers/convertwithmoss/core/AnalysisLogger.java new file mode 100644 index 00000000..6ccbb02d --- /dev/null +++ b/src/main/java/de/mossgrabers/convertwithmoss/core/AnalysisLogger.java @@ -0,0 +1,491 @@ +// 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; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Optional; + +import de.mossgrabers.convertwithmoss.core.model.IAudioMetadata; +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.ILfo; +import de.mossgrabers.convertwithmoss.core.model.ILfoModulator; +import de.mossgrabers.convertwithmoss.core.model.IMetadata; +import de.mossgrabers.convertwithmoss.core.model.IModulator; +import de.mossgrabers.convertwithmoss.core.model.ISampleData; +import de.mossgrabers.convertwithmoss.core.model.ISampleLoop; +import de.mossgrabers.convertwithmoss.core.model.ISampleZone; +import de.mossgrabers.convertwithmoss.core.model.enumeration.PlayLogic; +import de.mossgrabers.convertwithmoss.core.model.enumeration.TriggerType; + + +/** + * Logs the details of a detected source during an analysis run: the mapping of its zones, the + * sample format, loops, envelopes, LFOs and the filter. Only attributes which the source actually + * uses are logged, so searching the log finds the sources which use a specific feature - e.g. a + * test file for development. The details are logged as they were read from the source, before any + * processing is applied. + *

+ * The attribute names are deliberately not translated, so that a search pattern does not depend on + * the language of the user interface. + * + * @author Jürgen Moßgraber + */ +public class AnalysisLogger +{ + private static final String IDS_NOTIFY_ANALYSIS_DETAILS = "IDS_NOTIFY_ANALYSIS_DETAILS"; + + private static final String [] NOTE_NAMES = + { + "C", + "C#", + "D", + "D#", + "E", + "F", + "F#", + "G", + "G#", + "A", + "A#", + "B" + }; + + + /** + * Private due to utility class. + */ + private AnalysisLogger () + { + // Intentionally empty + } + + + /** + * Log the details of a multi-sample source. + * + * @param notifier Where to log to + * @param multisampleSource The multi-sample source to log + */ + public static void log (final INotifier notifier, final IMultisampleSource multisampleSource) + { + notifier.log (IDS_NOTIFY_ANALYSIS_DETAILS, multisampleSource.getName ()); + + final StringBuilder sb = new StringBuilder (); + appendMultisample (sb, multisampleSource, " "); + notifier.logText (sb.toString ()); + } + + + /** + * Log the details of a performance source with all of its instruments. + * + * @param notifier Where to log to + * @param performanceSource The performance source to log + */ + public static void log (final INotifier notifier, final IPerformanceSource performanceSource) + { + notifier.log (IDS_NOTIFY_ANALYSIS_DETAILS, performanceSource.getName ()); + + final StringBuilder sb = new StringBuilder (); + final List instrumentSources = performanceSource.getInstruments (); + for (int i = 0; i < instrumentSources.size (); i++) + { + final IInstrumentSource instrumentSource = instrumentSources.get (i); + final IMultisampleSource multisampleSource = instrumentSource.getMultisampleSource (); + + sb.append (" Instrument ").append (i + 1).append (" '").append (multisampleSource.getName ()).append ("': MIDI channel "); + final int midiChannel = instrumentSource.getMidiChannel (); + if (midiChannel >= 0 && midiChannel <= 15) + sb.append (midiChannel + 1); + else + sb.append ("omni"); + final int clipKeyLow = instrumentSource.getClipKeyLow (); + final int clipKeyHigh = instrumentSource.getClipKeyHigh (); + if (clipKeyLow > 0 || clipKeyHigh < 127) + sb.append (", keys ").append (formatNote (clipKeyLow)).append ("..").append (formatNote (clipKeyHigh)); + sb.append ('\n'); + + appendMultisample (sb, multisampleSource, " "); + } + notifier.logText (sb.toString ()); + } + + + private static void appendMultisample (final StringBuilder sb, final IMultisampleSource multisampleSource, final String indent) + { + final File sourceFile = multisampleSource.getSourceFile (); + if (sourceFile != null) + sb.append (indent).append ("File: ").append (sourceFile.getAbsolutePath ()).append ('\n'); + + appendMetadata (sb, multisampleSource.getMetadata (), indent); + + final int polyphony = multisampleSource.getPolyphony (); + if (polyphony > 0) + sb.append (indent).append ("Polyphony: ").append (polyphony).append (polyphony == 1 ? " voice" : " voices").append ('\n'); + final double portamentoTime = multisampleSource.getPortamentoTime (); + if (portamentoTime > 0) + sb.append (indent).append ("Portamento: ").append (formatSeconds (portamentoTime)).append ('\n'); + + final List groups = multisampleSource.getGroups (); + for (int i = 0; i < groups.size (); i++) + appendGroup (sb, groups.get (i), i + 1, indent); + } + + + private static void appendMetadata (final StringBuilder sb, final IMetadata metadata, final String indent) + { + final List tokens = new ArrayList<> (); + + final String category = metadata.getCategory (); + if (category != null && !category.isBlank ()) + tokens.add ("category '" + category + "'"); + final String creator = metadata.getCreator (); + if (creator != null && !creator.isBlank ()) + tokens.add ("creator '" + creator + "'"); + final String [] keywords = metadata.getKeywords (); + if (keywords != null && keywords.length > 0) + { + final StringBuilder keywordText = new StringBuilder ("keywords "); + for (int i = 0; i < keywords.length; i++) + { + if (i > 0) + keywordText.append (", "); + keywordText.append ('\'').append (keywords[i]).append ('\''); + } + tokens.add (keywordText.toString ()); + } + final String description = metadata.getDescription (); + if (description != null && !description.isBlank ()) + tokens.add ("description (" + description.length () + " characters)"); + + if (!tokens.isEmpty ()) + sb.append (indent).append ("Metadata: ").append (String.join (", ", tokens)).append ('\n'); + } + + + private static void appendGroup (final StringBuilder sb, final IGroup group, final int groupNumber, final String indent) + { + final List zones = group.getSampleZones (); + + sb.append (indent).append ("Group ").append (groupNumber); + final String name = group.getName (); + if (name != null && !name.isBlank ()) + sb.append (" '").append (name).append ('\''); + sb.append (": ").append (zones.size ()).append (zones.size () == 1 ? " zone" : " zones"); + + final TriggerType trigger = group.getTrigger (); + if (trigger != null && trigger != TriggerType.ATTACK) + sb.append (", trigger ").append (trigger.name ().toLowerCase (Locale.US)); + if (group.getGain () != 0) + sb.append (", gain ").append (formatDecibels (group.getGain ())); + if (group.getPanning () != 0) + sb.append (", panning ").append (formatSignedPercent (group.getPanning ())); + if (group.getTuning () != 0) + sb.append (", tuning ").append (formatSemitones (group.getTuning ())); + sb.append ('\n'); + + final String zoneIndent = indent + " "; + for (final ISampleZone zone: zones) + appendZone (sb, zone, zoneIndent); + } + + + private static void appendZone (final StringBuilder sb, final ISampleZone zone, final String indent) + { + sb.append (indent).append ("Zone '").append (zone.getName ()).append ("': ").append (formatZoneMapping (zone)); + + if (zone.getGain () != 0) + sb.append (", gain ").append (formatDecibels (zone.getGain ())); + if (zone.getPanning () != 0) + sb.append (", panning ").append (formatSignedPercent (zone.getPanning ())); + if (zone.getTuning () != 0) + sb.append (", tuning ").append (formatSemitones (zone.getTuning ())); + sb.append ('\n'); + + final String featureIndent = indent + " "; + appendZoneFeatures (sb, zone, featureIndent); + } + + + private static String formatZoneMapping (final ISampleZone zone) + { + final List tokens = new ArrayList<> (); + + final int keyLow = Math.max (0, zone.getKeyLow ()); + final int keyHigh = zone.getKeyHigh () < 0 ? 127 : zone.getKeyHigh (); + tokens.add ("keys " + formatNote (keyLow) + ".." + formatNote (keyHigh)); + final int keyRoot = zone.getKeyRoot (); + tokens.add ("root " + (keyRoot < 0 ? "-" : formatNote (keyRoot))); + tokens.add ("velocity " + zone.getVelocityLow () + ".." + zone.getVelocityHigh ()); + + final Optional sampleData = zone.getSampleData (); + if (sampleData.isEmpty ()) + tokens.add ("no audio"); + else + try + { + final IAudioMetadata audioMetadata = sampleData.get ().getAudioMetadata (); + tokens.add (audioMetadata.getSampleRate () + " Hz"); + tokens.add (audioMetadata.getBitResolution () + " bits"); + final int channels = audioMetadata.getChannels (); + if (channels == 1) + tokens.add ("mono"); + else if (channels == 2) + tokens.add ("stereo"); + else + tokens.add (channels + " channels"); + tokens.add (audioMetadata.getNumberOfSamples () + " frames"); + } + catch (final IOException ex) + { + tokens.add ("audio not readable"); + } + + return String.join (", ", tokens); + } + + + private static void appendZoneFeatures (final StringBuilder sb, final ISampleZone zone, final String indent) + { + final int start = zone.getStart (); + final int stop = zone.getStop (); + if (start > 0) + sb.append (indent).append ("Play range: ").append (start).append ("..").append (stop).append (" frames\n"); + + if (zone.getNoteCrossfadeLow () > 0 || zone.getNoteCrossfadeHigh () > 0) + sb.append (indent).append ("Note cross-fade: low ").append (zone.getNoteCrossfadeLow ()).append (", high ").append (zone.getNoteCrossfadeHigh ()).append ('\n'); + if (zone.getVelocityCrossfadeLow () > 0 || zone.getVelocityCrossfadeHigh () > 0) + sb.append (indent).append ("Velocity cross-fade: low ").append (zone.getVelocityCrossfadeLow ()).append (", high ").append (zone.getVelocityCrossfadeHigh ()).append ('\n'); + + if (zone.getPlayLogic () == PlayLogic.ROUND_ROBIN) + { + sb.append (indent).append ("Round robin"); + if (zone.getSequencePosition () > 0) + sb.append (" #").append (zone.getSequencePosition ()); + sb.append ('\n'); + } + + final TriggerType trigger = zone.getTrigger (); + if (trigger != null && trigger != TriggerType.ATTACK) + sb.append (indent).append ("Trigger: ").append (trigger.name ().toLowerCase (Locale.US)).append ('\n'); + if (zone.isOneShot ()) + sb.append (indent).append ("One-shot\n"); + if (zone.isReversed ()) + sb.append (indent).append ("Reversed\n"); + if (zone.getExclusiveGroup () != 0) + sb.append (indent).append ("Exclusive group: ").append (zone.getExclusiveGroup ()).append ('\n'); + + final double keyTracking = zone.getKeyTracking (); + if (keyTracking != 1) + sb.append (indent).append ("Key tracking: ").append (formatPercent (keyTracking)).append (keyTracking == 0 ? " (fixed pitch)" : "").append ('\n'); + if (zone.getAmplitudeKeyTracking () != 0) + sb.append (indent).append ("Amplitude key tracking: ").append (String.format (Locale.US, "%+.2f dB/key", Double.valueOf (zone.getAmplitudeKeyTracking ()))).append ('\n'); + + if (zone.getBendUp () != 200 || zone.getBendDown () != -200) + sb.append (indent).append ("Pitch bend: up ").append (zone.getBendUp ()).append (" cents, down ").append (zone.getBendDown ()).append (" cents\n"); + + for (final ISampleLoop loop: zone.getLoops ()) + appendLoop (sb, loop, indent); + + final IModulator amplitudeVelocityModulator = zone.getAmplitudeVelocityModulator (); + if (amplitudeVelocityModulator.getDepth () != 1 || amplitudeVelocityModulator.getCurve () != 0) + appendModulator (sb, "Amplitude velocity", amplitudeVelocityModulator, formatSignedPercent (amplitudeVelocityModulator.getDepth ()), indent); + + final IEnvelopeModulator amplitudeEnvelopeModulator = zone.getAmplitudeEnvelopeModulator (); + if (amplitudeEnvelopeModulator.getSource ().isSet ()) + appendEnvelope (sb, "Amplitude envelope", amplitudeEnvelopeModulator, amplitudeEnvelopeModulator.getDepth () == 1 ? null : formatPercent (amplitudeEnvelopeModulator.getDepth ()), indent); + + final ILfoModulator amplitudeLfoModulator = zone.getAmplitudeLfoModulator (); + if (amplitudeLfoModulator.getDepth () != 0 && amplitudeLfoModulator.getSource ().isSet ()) + appendLfo (sb, "Tremolo", amplitudeLfoModulator, formatLfoVolumeDepth (amplitudeLfoModulator.getDepth ()), indent); + + final IEnvelopeModulator pitchEnvelopeModulator = zone.getPitchEnvelopeModulator (); + if (pitchEnvelopeModulator.getDepth () != 0 && pitchEnvelopeModulator.getSource ().isSet ()) + appendEnvelope (sb, "Pitch envelope", pitchEnvelopeModulator, formatCents (pitchEnvelopeModulator.getDepth ()), indent); + + final ILfoModulator pitchLfoModulator = zone.getPitchLfoModulator (); + if (pitchLfoModulator.getDepth () != 0 && pitchLfoModulator.getSource ().isSet ()) + appendLfo (sb, "Vibrato", pitchLfoModulator, formatCents (pitchLfoModulator.getDepth ()), indent); + + final Optional filter = zone.getFilter (); + if (filter.isPresent ()) + appendFilter (sb, filter.get (), indent); + } + + + private static void appendLoop (final StringBuilder sb, final ISampleLoop loop, final String indent) + { + sb.append (indent).append ("Loop: ").append (loop.getType ().name ().toLowerCase (Locale.US)).append (' ').append (loop.getStart ()).append ("..").append (loop.getEnd ()); + if (loop.isLoopUntilRelease ()) + sb.append (", until release"); + final int crossfadeInSamples = loop.getCrossfadeInSamples (); + if (crossfadeInSamples > 0) + sb.append (", cross-fade ").append (crossfadeInSamples).append (" frames"); + if (loop.getTuning () != 0) + sb.append (", tuning ").append (formatSemitones (loop.getTuning ())); + sb.append ('\n'); + } + + + private static void appendFilter (final StringBuilder sb, final IFilter filter, final String indent) + { + sb.append (indent).append ("Filter: ").append (filter.getType ().name ().toLowerCase (Locale.US).replace ('_', '-')); + if (filter.getPoles () > 0) + sb.append (", ").append (filter.getPoles ()).append (filter.getPoles () == 1 ? " pole" : " poles"); + sb.append (", cutoff ").append (String.format (Locale.US, "%.0f Hz", Double.valueOf (filter.getCutoff ()))); + if (filter.getResonance () != 0) + sb.append (", resonance ").append (String.format (Locale.US, "%.1f dB", Double.valueOf (filter.getResonance () * IFilter.MAX_RESONANCE))); + if (filter.getCutoffKeyTracking () != 0) + sb.append (", key tracking ").append (formatSignedPercent (filter.getCutoffKeyTracking ())); + sb.append ('\n'); + + final String modulatorIndent = indent + " "; + + final IModulator cutoffVelocityModulator = filter.getCutoffVelocityModulator (); + if (cutoffVelocityModulator.getDepth () != 0 || cutoffVelocityModulator.getCurve () != 0) + appendModulator (sb, "Filter velocity", cutoffVelocityModulator, formatFilterVelocityCents (cutoffVelocityModulator.getDepth ()), modulatorIndent); + + final IEnvelopeModulator cutoffEnvelopeModulator = filter.getCutoffEnvelopeModulator (); + if (cutoffEnvelopeModulator.getDepth () != 0 && cutoffEnvelopeModulator.getSource ().isSet ()) + appendEnvelope (sb, "Filter envelope", cutoffEnvelopeModulator, formatCents (cutoffEnvelopeModulator.getDepth ()), modulatorIndent); + + final ILfoModulator cutoffLfoModulator = filter.getCutoffLfoModulator (); + if (cutoffLfoModulator.getDepth () != 0 && cutoffLfoModulator.getSource ().isSet ()) + appendLfo (sb, "Filter LFO", cutoffLfoModulator, formatCents (cutoffLfoModulator.getDepth ()), modulatorIndent); + } + + + private static void appendModulator (final StringBuilder sb, final String label, final IModulator modulator, final String depthText, final String indent) + { + sb.append (indent).append (label).append (": depth ").append (depthText); + if (modulator.getCurve () != 0) + sb.append (", curve ").append (String.format (Locale.US, "%+.2f", Double.valueOf (modulator.getCurve ()))); + sb.append ('\n'); + } + + + private static void appendEnvelope (final StringBuilder sb, final String label, final IEnvelopeModulator modulator, final String depthText, final String indent) + { + final List tokens = new ArrayList<> (); + if (depthText != null) + tokens.add ("depth " + depthText); + + final IEnvelope envelope = modulator.getSource (); + if (envelope.getDelayTime () >= 0) + tokens.add ("delay " + formatSeconds (envelope.getDelayTime ())); + if (envelope.getStartLevel () >= 0) + tokens.add ("start level " + formatPercent (envelope.getStartLevel ())); + if (envelope.getAttackTime () >= 0) + tokens.add ("attack " + formatSeconds (envelope.getAttackTime ()) + formatSlope (envelope.getAttackSlope ())); + if (envelope.getHoldLevel () >= 0) + tokens.add ("hold level " + formatPercent (envelope.getHoldLevel ())); + if (envelope.getHoldTime () >= 0) + tokens.add ("hold " + formatSeconds (envelope.getHoldTime ())); + if (envelope.getDecayTime () >= 0) + tokens.add ("decay " + formatSeconds (envelope.getDecayTime ()) + formatSlope (envelope.getDecaySlope ())); + if (envelope.getSustainLevel () >= 0) + tokens.add ("sustain " + formatPercent (envelope.getSustainLevel ())); + if (envelope.getReleaseTime () >= 0) + tokens.add ("release " + formatSeconds (envelope.getReleaseTime ()) + formatSlope (envelope.getReleaseSlope ())); + if (envelope.getEndLevel () >= 0) + tokens.add ("end level " + formatPercent (envelope.getEndLevel ())); + if (envelope.getTimeKeyTracking () != 0) + tokens.add ("time key tracking " + formatSignedPercent (envelope.getTimeKeyTracking ())); + if (envelope.getTimeVelocityTracking () != 0) + tokens.add ("time velocity tracking " + formatSignedPercent (envelope.getTimeVelocityTracking ())); + + sb.append (indent).append (label).append (": ").append (String.join (", ", tokens)).append ('\n'); + } + + + private static void appendLfo (final StringBuilder sb, final String label, final ILfoModulator modulator, final String depthText, final String indent) + { + final List tokens = new ArrayList<> (); + + final ILfo lfo = modulator.getSource (); + if (lfo.getRate () >= 0) + tokens.add (String.format (Locale.US, "%.2f Hz", Double.valueOf (lfo.getRate ()))); + tokens.add ("depth " + depthText); + if (lfo.getDelay () >= 0) + tokens.add ("delay " + formatSeconds (lfo.getDelay ())); + if (lfo.getFadeIn () >= 0) + tokens.add ("fade-in " + formatSeconds (lfo.getFadeIn ())); + if (lfo.getStartPhase () > 0) + tokens.add ("start phase " + formatPercent (lfo.getStartPhase ())); + if (lfo.isKeySync ()) + tokens.add ("key sync"); + + sb.append (indent).append (label).append (": ").append (String.join (", ", tokens)).append ('\n'); + } + + + private static String formatNote (final int midiNote) + { + return NOTE_NAMES[midiNote % 12] + (midiNote / 12 - 2) + "/" + midiNote; + } + + + private static String formatSeconds (final double seconds) + { + return String.format (Locale.US, "%.3f s", Double.valueOf (seconds)); + } + + + private static String formatDecibels (final double decibels) + { + return String.format (Locale.US, "%+.1f dB", Double.valueOf (decibels)); + } + + + private static String formatSemitones (final double semitones) + { + return String.format (Locale.US, "%+.2f semi-tones", Double.valueOf (semitones)); + } + + + private static String formatPercent (final double value) + { + return Math.round (value * 100) + " %"; + } + + + private static String formatSignedPercent (final double value) + { + return String.format (Locale.US, "%+d %%", Long.valueOf (Math.round (value * 100))); + } + + + private static String formatCents (final double depth) + { + return String.format (Locale.US, "%+d cents", Long.valueOf (Math.round (depth * IEnvelope.MAX_ENVELOPE_DEPTH))); + } + + + private static String formatFilterVelocityCents (final double depth) + { + // The full scale of the cutoff velocity modulator are 8 octaves, see e.g. its SFZ + // fil_veltrack round-trip + return String.format (Locale.US, "%+d cents", Long.valueOf (Math.round (depth * 9600))); + } + + + private static String formatLfoVolumeDepth (final double depth) + { + return String.format (Locale.US, "%.1f dB", Double.valueOf (Math.abs (depth) * ILfoModulator.MAX_VOLUME_DEPTH)); + } + + + private static String formatSlope (final double slope) + { + return slope == 0 ? "" : String.format (Locale.US, " (slope %+.2f)", Double.valueOf (slope)); + } +} diff --git a/src/main/java/de/mossgrabers/convertwithmoss/core/CLIBackend.java b/src/main/java/de/mossgrabers/convertwithmoss/core/CLIBackend.java index ce3c6f8b..2a9fd4bf 100644 --- a/src/main/java/de/mossgrabers/convertwithmoss/core/CLIBackend.java +++ b/src/main/java/de/mossgrabers/convertwithmoss/core/CLIBackend.java @@ -97,6 +97,7 @@ public void parseCommandLine (final String [] arguments) spec.addOption (OptionSpec.builder ("-d", "--destination").paramLabel ("DESTINATION").type (String.class).description ("The destination format.").required (true).build ()); spec.addOption (OptionSpec.builder ("-t", "--type").paramLabel ("TYPE").type (String.class).description ("Set to either 'preset' (the default if absent) or 'performance' (without the quotes).").build ()); spec.addOption (OptionSpec.builder ("-a", "--analyze").paramLabel ("ANALYZE").description ("If present, only analyzes the potential source files.").build ()); + spec.addOption (OptionSpec.builder ("-ad", "--analyze-details").paramLabel ("ANALYZE_DETAILS").description ("Like --analyze, but additionally logs for every found multi-sample what it contains: the mapping of its zones, the sample format, loops, envelopes, LFOs and the filter. Only attributes which a source actually uses are logged, so searching the output finds the source files which use a specific feature.").build ()); spec.addOption (OptionSpec.builder ("-f", "--flat").paramLabel ("FLAT").description ("If present, the folder structure is not recreated in the output folder.").build ()); spec.addOption (OptionSpec.builder ("-l", "--library").paramLabel ("LIBRARY").type (String.class).description ("Name for the library. Set to create a library.").build ()); spec.addOption (OptionSpec.builder ("-p").paramLabel ("KEY=VALUE").description ("Key-value pairs in the form -pkey1=value1,key2=value2,...").required (false).arity ("0..*").type (Map.class).auxiliaryTypes (String.class, String.class).defaultValue (null).build ()); @@ -226,7 +227,8 @@ private int run (final ParseResult parseResult) detectSettings.libraryName = parseResult.matchedOptionValue ('l', null); detectSettings.wantsMultipleFiles = detectSettings.libraryName != null; detectSettings.createFolderStructure = parseResult.matchedOptionValue ('f', null) == null; - final boolean onlyAnalyse = parseResult.matchedOptionValue ('a', null) != null; + detectSettings.logAnalysisDetails = parseResult.matchedOptionValue ("ad", null) != null; + final boolean onlyAnalyse = detectSettings.logAnalysisDetails || parseResult.matchedOptionValue ('a', null) != null; // Creating a library or a performance is an empty operation for a destination which does // not support it - everything detected would be collected and then silently dropped. diff --git a/src/main/java/de/mossgrabers/convertwithmoss/core/ConverterBackend.java b/src/main/java/de/mossgrabers/convertwithmoss/core/ConverterBackend.java index 51b75b0d..996a38bd 100644 --- a/src/main/java/de/mossgrabers/convertwithmoss/core/ConverterBackend.java +++ b/src/main/java/de/mossgrabers/convertwithmoss/core/ConverterBackend.java @@ -460,6 +460,11 @@ private void acceptMultisample (final IMultisampleSource multisampleSource) if (!this.isSelected (multisampleSource.getSourceFile (), index)) return; + // Log before the source is processed, so the log describes what the source contains and + // not what the processing added (e.g. the category based default envelope) + if (this.onlyAnalyse && this.detectionSettings.logAnalysisDetails) + AnalysisLogger.log (this.notifier, multisampleSource); + this.processSource (multisampleSource); if (this.detectionSettings.wantsMultipleFiles) @@ -514,6 +519,11 @@ private void acceptPerformance (final IPerformanceSource performanceSource) if (!this.isSelected (sourceFile, index)) return; + // Log before the sources are processed, so the log describes what the source contains + // and not what the processing added (e.g. the category based default envelope) + if (this.onlyAnalyse && this.detectionSettings.logAnalysisDetails) + AnalysisLogger.log (this.notifier, performanceSource); + for (final IInstrumentSource instrumentSource: instrumentSources) this.processSource (instrumentSource.getMultisampleSource ()); diff --git a/src/main/java/de/mossgrabers/convertwithmoss/core/DetectSettings.java b/src/main/java/de/mossgrabers/convertwithmoss/core/DetectSettings.java index 918bd953..cd00bdc4 100644 --- a/src/main/java/de/mossgrabers/convertwithmoss/core/DetectSettings.java +++ b/src/main/java/de/mossgrabers/convertwithmoss/core/DetectSettings.java @@ -41,6 +41,8 @@ public class DetectSettings public boolean wantsMultipleFiles; /** True, if the source folder structure should be replicated in the output folder. */ public boolean createFolderStructure; + /** True, if an analysis run should log the details of every found source. */ + public boolean logAnalysisDetails; // Parameters for Processing diff --git a/src/main/java/de/mossgrabers/convertwithmoss/ui/MainFrame.java b/src/main/java/de/mossgrabers/convertwithmoss/ui/MainFrame.java index 6b1beb56..fdccfa93 100644 --- a/src/main/java/de/mossgrabers/convertwithmoss/ui/MainFrame.java +++ b/src/main/java/de/mossgrabers/convertwithmoss/ui/MainFrame.java @@ -92,6 +92,7 @@ public class MainFrame extends AbstractFrame implements INotifier private static final int MAXIMUM_NUMBER_OF_LOG_ENTRIES = 100000; private static final String ENABLE_DARK_MODE = "EnableDarkMode"; + private static final String ANALYSIS_DETAILS = "AnalysisDetails"; private static final String DESTINATION_CREATE_FOLDER_STRUCTURE = "DestinationCreateFolderStructure"; private static final String DESTINATION_ADD_NEW_FILES = "DestinationAddNewFiles"; private static final String DESTINATION_FORMAT = "DestinationFormat"; @@ -583,6 +584,7 @@ private void loadConfiguration () this.detectSettings.createFolderStructure = this.config.getBoolean (DESTINATION_CREATE_FOLDER_STRUCTURE, true); this.addNewFiles = this.config.getBoolean (DESTINATION_ADD_NEW_FILES, false); + this.detectSettings.logAnalysisDetails = this.config.getBoolean (ANALYSIS_DETAILS, false); this.enableDarkMode = this.config.getBoolean (ENABLE_DARK_MODE, false); this.setDarkMode (this.enableDarkMode); @@ -636,6 +638,7 @@ private void saveConfiguration () this.config.setBoolean (DESTINATION_CREATE_FOLDER_STRUCTURE, this.detectSettings.createFolderStructure); this.config.setBoolean (DESTINATION_ADD_NEW_FILES, this.addNewFiles); + this.config.setBoolean (ANALYSIS_DETAILS, this.detectSettings.logAnalysisDetails); this.config.setBoolean (ENABLE_DARK_MODE, this.enableDarkMode); } @@ -668,6 +671,7 @@ private void openSettings () { this.settingsDialog.createFolderStructureCheckbox.setSelected (this.detectSettings.createFolderStructure); this.settingsDialog.addNewFilesCheckbox.setSelected (this.addNewFiles); + this.settingsDialog.analysisDetailsCheckbox.setSelected (this.detectSettings.logAnalysisDetails); this.settingsDialog.enableDarkModeCheckbox.setSelected (this.enableDarkMode); this.settingsDialog.display ().thenAccept (result -> { @@ -675,6 +679,7 @@ private void openSettings () { this.detectSettings.createFolderStructure = this.settingsDialog.createFolderStructureCheckbox.isSelected (); this.addNewFiles = this.settingsDialog.addNewFilesCheckbox.isSelected (); + this.detectSettings.logAnalysisDetails = this.settingsDialog.analysisDetailsCheckbox.isSelected (); this.enableDarkMode = this.settingsDialog.enableDarkModeCheckbox.isSelected (); this.setDarkMode (this.enableDarkMode); diff --git a/src/main/java/de/mossgrabers/convertwithmoss/ui/SettingsDialog.java b/src/main/java/de/mossgrabers/convertwithmoss/ui/SettingsDialog.java index 823919b1..0716fc3d 100644 --- a/src/main/java/de/mossgrabers/convertwithmoss/ui/SettingsDialog.java +++ b/src/main/java/de/mossgrabers/convertwithmoss/ui/SettingsDialog.java @@ -24,6 +24,8 @@ public class SettingsDialog extends PseudoModalDialog public CheckBox createFolderStructureCheckbox; /** Check-box for only adding new files option. */ public CheckBox addNewFilesCheckbox; + /** Check-box for logging the analysis details option. */ + public CheckBox analysisDetailsCheckbox; /** Check-box for enabling the dark mode option. */ public CheckBox enableDarkModeCheckbox; @@ -49,12 +51,14 @@ protected Pane init () this.createFolderStructureCheckbox = panel.createCheckBox ("@IDS_MAIN_CREATE_FOLDERS", "@IDS_MAIN_CREATE_FOLDERS_TOOLTIP"); this.addNewFilesCheckbox = panel.createCheckBox ("@IDS_MAIN_ADD_NEW", "@IDS_MAIN_ADD_NEW_TOOLTIP"); + this.analysisDetailsCheckbox = panel.createCheckBox ("@IDS_MAIN_ANALYSIS_DETAILS", "@IDS_MAIN_ANALYSIS_DETAILS_TOOLTIP"); this.enableDarkModeCheckbox = panel.createCheckBox ("@IDS_MAIN_ENABLE_DARK_MODE", "@IDS_MAIN_ENABLE_DARK_MODE_TOOLTIP"); this.setButtons ("@IDS_SETTINGS_DLG_OK", "@IDS_SETTINGS_DLG_CANCEL"); this.traversalManager.add (this.createFolderStructureCheckbox); this.traversalManager.add (this.addNewFilesCheckbox); + this.traversalManager.add (this.analysisDetailsCheckbox); this.traversalManager.add (this.enableDarkModeCheckbox); 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 03abac6d..109bbab5 100644 --- a/src/main/resources/Strings.properties +++ b/src/main/resources/Strings.properties @@ -20,6 +20,7 @@ IDS_NOTIFY_DETECTING_NO_CONVERSION=\nDetecting multi-samples from %1...\n IDS_NOTIFY_ANALYZING=\nAnalyzing: %1\n IDS_NOTIFY_COLLECTING=Collecting: %1\n IDS_NOTIFY_ANALYZE_OK=Analyze: '%1' OK\n +IDS_NOTIFY_ANALYSIS_DETAILS=\nAnalysis of '%1':\n IDS_NOTIFY_STORING=Storing: %1\n IDS_NOTIFY_ALREADY_EXISTS=File does already exist. Skipped: %1\n IDS_NOTIFY_ERROR_SAMPLE_FOLDER=Could not create sample folder: %1\n @@ -703,6 +704,8 @@ IDS_MAIN_CREATE_FOLDERS=Create folder structure IDS_MAIN_CREATE_FOLDERS_TOOLTIP=Recreates the folder structure which is found below the source folder in the destination folder. IDS_MAIN_ADD_NEW=Add new files IDS_MAIN_ADD_NEW_TOOLTIP=Starts the conversion even if the output folder is not empty but only adds files which are not already present. +IDS_MAIN_ANALYSIS_DETAILS=Log analysis details +IDS_MAIN_ANALYSIS_DETAILS_TOOLTIP=If enabled, the Analyse run logs for every found multi-sample what it contains: the mapping of its zones, the sample format, loops, envelopes, LFOs and the filter. Only attributes which a source actually uses are logged, so searching the log finds the sources which use a specific feature. The details describe the source as it was read, before any processing. IDS_MAIN_ENABLE_DARK_MODE=Dark Mode IDS_MAIN_ENABLE_DARK_MODE_TOOLTIP=Toggle between a light and a dark layout