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 @@ -53,6 +53,7 @@
* Teenage Engineering OP-XY
* Fixed: The samples which do not make it into the written preset - the dropped velocity layers and the regions beyond the maximum of 24 - were still copied into the preset folder, although the description file does not reference them. They only occupied space on the device and could make the preset folder too big to load. Only the samples of the written regions are stored now.
* Waldorf Quantum/Iridium
* Fixed: A percussive sound lost its attack. The instant attack which such a source asks for gates the VCA open in one sample, which clicks, so every short amplitude attack was lifted to the shortest length the device can play - and that erases the strike of a drum, a plucked string or a piano. The audio is now inspected: the stage is only lifted when the first sample of a zone is far enough from zero to be heard as a click (2 % of its own peak, the same ratio at which a loop wrap becomes audible), and a source which fades in from silence keeps its instant attack. The device itself uses an attack of zero in 35 % of its factory presets.
* Fixed: The key tracking of the filter was written at twice the intended amount. The parameter spans -200 % to +200 % - the same scale the key tracking of an oscillator uses, where +100 % is the 1:1 tracking of the manual - but it was written over -100 % to +100 %, so a source which tracks the keyboard fully ended up at +200 % on the device (verified on an Iridium MK2). Such a preset opens its filter twice as far towards the top of the keyboard as the source does, and since the cut-off runs into its limit early the upper half of the range sounds equally bright. Reading is corrected the same way, which affects the presets of the device itself: a filter key tracking read from a preset was half of what the device shows.
* Fixed: A layer which the source pans as a whole is now panned on the oscillator which plays it. The panning of the single entries of a sample map is ignored by the device, so a preset which places its layers left and right - the way samplers build a wide, detuned sound, e.g. 'DIGIPAD ST' of East West's Twisted Textures, whose two layers sit hard left and hard right six cents apart - collapsed to the centre. Layers whose zones are not panned alike are left in the centre as before, and of 264 oscillators across a converted CD-ROM 166 stay centred while the presets which gain a panning are the ones whose names announce a stereo spread.
* Fixed: Reading a preset shifted the sample start/end and loop points of many zones one frame down. The positions are stored as a fraction of the sample length with 8 decimal places, which can land marginally below the exact frame boundary (frame 3977 of 5469 is written as 0.72718961, and 0.72718961 * 5469 = 3976.9999787); the fraction was truncated instead of rounded, which loses one frame for every such position - on a converted card of 165 presets, 759 of 2270 loop positions were affected. The device's own exported presets write fractions which land marginally below the frame the same way, so rounding to the nearest frame is also what the device does when it reads them.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ private static int nearestRisingZeroCrossing (final int [] signal, final int pos
* @throws IOException Could not read the audio
* @throws UnsupportedAudioFileException The audio format is not supported
*/
static int [] readMonoSignal (final ISampleZone zone) throws IOException, UnsupportedAudioFileException
public static int [] readMonoSignal (final ISampleZone zone) throws IOException, UnsupportedAudioFileException
{
final ByteArrayOutputStream out = new ByteArrayOutputStream ();
final Optional<ISampleData> sampleData = zone.getSampleData ();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@
import java.util.Map;
import java.util.Optional;

import javax.sound.sampled.UnsupportedAudioFileException;

import de.mossgrabers.convertwithmoss.core.algorithm.LoopZeroSnapper;
import de.mossgrabers.convertwithmoss.core.IMultisampleSource;
import de.mossgrabers.convertwithmoss.core.INotifier;
import de.mossgrabers.convertwithmoss.core.creator.AbstractWavCreator;
Expand Down Expand Up @@ -64,6 +67,8 @@ public class WaldorfQpatCreator extends AbstractWavCreator<WaldorfQpatCreatorUI>
private static final WaldorfQpatResourceHeader EMPTY_RESOURCE_HEADER = new WaldorfQpatResourceHeader ();
/** The shortest amplitude attack/release which the device renders without a click. */
private static final double DECLICK_SECONDS = 0.07;
/** The share of the peak level at which a step in the audio becomes audible as a click. */
private static final double AUDIBLE_STEP_RATIO = 0.02;

private static final DestinationAudioFormat OPTIMIZED_AUDIO_FORMAT = new DestinationAudioFormat (new int []
{
Expand Down Expand Up @@ -643,7 +648,10 @@ private static List<WaldorfQpatParameter> createParameters (final List<IGroup> g

final IEnvelopeModulator amplitudeEnvelopeModulator = firstZone.getAmplitudeEnvelopeModulator ();
final IEnvelope envelope = amplitudeEnvelopeModulator.getSource ();
createEnvelope (parameters, envelope, AMP_ENV, AMP_ENV, flattenAmpEnvelope);
// The audio is only inspected when the attack is short enough to be affected at all
final double sourceAttackTime = envelope.getAttackTime ();
final boolean allowInstantAttack = sourceAttackTime > 0 && sourceAttackTime < DECLICK_SECONDS && !startsWithAudibleStep (groups);
createEnvelope (parameters, envelope, AMP_ENV, AMP_ENV, flattenAmpEnvelope, allowInstantAttack);

// AmpVeloAmount: [0.00] "-100.00 %" ... [1.00] "+100.00 %"
final double ampVeloAmount = firstZone.getAmplitudeVelocityModulator ().getDepth ();
Expand Down Expand Up @@ -678,7 +686,7 @@ private static void createPitchEnvelopeModulator (final List<WaldorfQpatParamete
parameters.add (new WaldorfQpatParameter ("MatrixAmount" + oscIndex, depthStr, (float) ((depth + 1.0) / 2.0)));

final String prefix = "FreeEnv" + oscIndex;
createEnvelope (parameters, pitchEnvelopeModulator.getSource (), prefix, prefix, false);
createEnvelope (parameters, pitchEnvelopeModulator.getSource (), prefix, prefix, false, false);
}


Expand Down Expand Up @@ -751,11 +759,11 @@ private static void createFilterParameters (final List<WaldorfQpatParameter> par
final double keyTracking = filter.getCutoffKeyTracking ();
parameters.add (new WaldorfQpatParameter ("Filter1Keytrack", StringUtils.formatPercent (keyTracking, 2), (float) Math.clamp ((keyTracking + 2.0) / 4.0, 0, 1)));

createEnvelope (parameters, modulator.getSource (), "Filter1Env", "Filter1", false);
createEnvelope (parameters, modulator.getSource (), "Filter1Env", "Filter1", false, false);
}


private static void createEnvelope (final List<WaldorfQpatParameter> parameters, final IEnvelope envelope, final String prefix, final String slopePrefix, final boolean flattenSustain)
private static void createEnvelope (final List<WaldorfQpatParameter> parameters, final IEnvelope envelope, final String prefix, final String slopePrefix, final boolean flattenSustain, final boolean allowInstantAttack)
{
final boolean isPitch = prefix.startsWith ("Free");
// Only the amplitude envelope gates the VCA, so only it can click when a stage is instant;
Expand All @@ -778,7 +786,7 @@ private static void createEnvelope (final List<WaldorfQpatParameter> parameters,
final double delayTime = Math.clamp (envelope.getDelayTime (), 0, 2);
parameters.add (new WaldorfQpatParameter (prefix + "Delay", formatSeconds (delayTime), (float) convertFromDelayTime (delayTime)));
// xxxEnvAttack
final double attackTime = declickAmpTime (isAmplitude, Math.clamp (envelope.getAttackTime (), 0, 60));
final double attackTime = declickAmpTime (isAmplitude && !allowInstantAttack, Math.clamp (envelope.getAttackTime (), 0, 60));
parameters.add (new WaldorfQpatParameter (prefix + "Attack", formatSeconds (attackTime), (float) convertFromTime (attackTime)));
// xxxEnvDecay
final double decayTime = Math.clamp (Math.max (0, envelope.getHoldTime ()) + Math.max (0, envelope.getDecayTime ()), 0, 60);
Expand Down Expand Up @@ -1000,13 +1008,55 @@ private static double convertFromDelayTime (final double y)
* Iridium hardware); a genuine zero stays instant. Only the amplitude envelope gates the VCA,
* so a short filter or pitch envelope stage is left unchanged.
*
* @param isAmplitude True if this is the amplitude (VCA) envelope
* @param declick True to lift the stage to the shortest audible length
* @param seconds The envelope stage time in seconds
* @return The de-clicked time in seconds
*/
private static double declickAmpTime (final boolean isAmplitude, final double seconds)
private static double declickAmpTime (final boolean declick, final double seconds)
{
return declick && seconds > 0 ? Math.max (seconds, DECLICK_SECONDS) : seconds;
}


/**
* Test whether the audio of any zone starts with a step which is large enough to be heard as a
* click when the amplitude envelope opens the VCA instantly. Only such a source is worth the
* loss of its attack transient, which lifting the stage to the shortest length of the device
* costs. The step is measured against the peak level of the same audio, so it does not depend
* on the bit resolution, and it uses the ratio at which a step becomes audible at a loop wrap.
*
* @param groups The groups of the multi-sample
* @return True if a zone starts with an audible step or its audio could not be read
*/
private static boolean startsWithAudibleStep (final List<IGroup> groups)
{
return isAmplitude && seconds > 0 ? Math.max (seconds, DECLICK_SECONDS) : seconds;
for (final IGroup group: groups)
for (final ISampleZone zone: group.getSampleZones ())
{
final int [] signal;
try
{
signal = LoopZeroSnapper.readMonoSignal (zone);
}
catch (final IOException | UnsupportedAudioFileException _)
{
// The audio cannot be judged, therefore keep the variant which cannot click
return true;
}
if (signal.length == 0)
continue;

int peak = 0;
for (final int value: signal)
peak = Math.max (peak, Math.abs (value));
if (peak == 0)
continue;

final int start = Math.clamp (zone.getStart (), 0, signal.length - 1);
if (Math.abs (signal[start]) > peak * AUDIBLE_STEP_RATIO)
return true;
}
return false;
}


Expand Down