Kontakt loop tune (ZoneLoop.loopTuning) is parsed but never propagated to output formats
Summary
When converting an NKI from Kontakt 5+ to any output format (SFZ tested), the per-loop tuning value that the user set in Kontakt's mapping editor is silently dropped. Other loop parameters (start, end, crossfade, mode) are preserved correctly.
The value is read correctly from the binary and exposed via ZoneLoop.getLoopTuning(), but no caller of that getter exists anywhere in the project.
Reproducer
- Open Kontakt, load any multi-sample patch with looped zones.
- In the mapping editor, set a non-zero loop tune for one zone (e.g. -50 cents).
- Save as
.nki.
- Convert with
ConvertWithMoss (17.1.0) to SFZ:
- The generated SFZ contains
loop_start=, loop_end=, loop_crossfade=, but no loop_tune= opcode.
- The same is true for DecentSampler / EXS24 / other writers — none of them receive the value.
Root cause (read direction)
format/ni/kontakt/type/kontakt5/ZoneLoop.java correctly parses the value:
// line 33
private float loopTuning;
// line 50 — inside read(InputStream in)
this.loopTuning = StreamUtils.readFloatLE (in);
// line 140 — public getter
public float getLoopTuning () { return this.loopTuning; }
But the only place that builds an ISampleLoop out of a ZoneLoop — format/ni/kontakt/type/AbstractKontaktFormat.java lines 181-192 — never calls getLoopTuning():
for (final ZoneLoop zoneLoop: kontaktZone.getLoops ()) {
final ISampleLoop loop = new DefaultSampleLoop ();
final int loopMode = zoneLoop.getMode ();
if (loopMode == ZoneLoop.MODE_UNTIL_END || loopMode == ZoneLoop.MODE_UNTIL_RELEASE) {
loop.setType (zoneLoop.isAlternating () ? LoopType.ALTERNATING : LoopType.FORWARDS);
loop.setStart (zoneLoop.getLoopStart ());
loop.setEnd (zoneLoop.getLoopStart () + zoneLoop.getLoopLength ());
loop.setCrossfadeInSamples (zoneLoop.getCrossfadeLength ());
// <-- zoneLoop.getLoopTuning() is never read here
zone.addLoop (loop);
}
}
A grep over the whole repo confirms getLoopTuning has zero call sites outside the declaring class.
Root cause (write direction)
ISampleLoop (and DefaultSampleLoop) have no tuning field. SFZ writer (format/sfz/SfzCreator.java::createLoops) emits only loop_start, loop_end, optional loop_crossfade. SfzOpcode.java has no LOOP_TUNE constant.
So even if the read side were fixed, no writer would emit it.
Why this matters
The SFZ v2 loop_tune opcode (and ARIA alias looptune) lets the loop region play at a different pitch than the attack/tail. This is a real-world feature heavily used in commercial Kontakt libraries to keep the looped sustain in tune when the original loop slice has a slightly different fundamental than the root key. Losing it on conversion forces users to re-discover and re-enter the value by hand for every zone — for a 127-zone bass instrument this is hours of work.
Suggested fix
I sketched the minimal set of changes; happy to send it as a PR if the approach is acceptable. Open question on units (see below).
1. core/model/ISampleLoop.java
Add two methods, defaulting to 0 for backwards compatibility:
/** Loop tune in cents, relative to the zone root. 0 = no offset. */
double getTuning ();
void setTuning (double cents);
2. core/model/implementation/DefaultSampleLoop.java
private double tuning = 0.0;
@Override public double getTuning () { return this.tuning; }
@Override public void setTuning (double cents) { this.tuning = cents; }
3. format/ni/kontakt/type/AbstractKontaktFormat.java line 190
loop.setCrossfadeInSamples (zoneLoop.getCrossfadeLength ());
loop.setTuning (zoneLoop.getLoopTuning () * 100.0); // <-- new
zone.addLoop (loop);
Open question for maintainer: what is the unit of the raw float loopTuning in the Kontakt 5 binary? AbstractKontaktFormat.calculateTune and K1Tag.calculateTune both apply 12 * log2(ratio) to the multiplicative tune values, returning semitones. But loopTuning is read as a raw float with no transform. If it is already in semitones (matching the calculateTune output), the multiplier above is correct (* 100 to convert to cents). If it is in cents directly, drop the * 100. If it is a ratio like the other tune fields, the conversion is 12 * log2(loopTuning) * 100. I do not have a clean way to verify this without a Kontakt-saved NKI plus a way to inspect what Kontakt itself displays for that zone — your call.
4. format/sfz/SfzOpcode.java
public static final String LOOP_TUNE = "loop_tune";
5. format/sfz/SfzCreator.java::createLoops (around line 420)
final double tuning = sampleLoop.getTuning ();
if (tuning != 0.0)
buffer.append (' ').append (SfzOpcode.LOOP_TUNE).append ('=').append (tuning);
Other writers (DecentSampler etc.) can opt in later as needed.
Scope notes
- Read direction (NKI -> ISampleLoop) is straightforward.
- Write direction back into NKI is not in scope here —
Kontakt5Format.writeNKM is currently // Not supported anyway, so this only affects export to other formats.
- Backwards compatibility: default value is 0, so existing presets and existing detectors stay bit-identical.
Happy to open a PR once the unit question above is resolved, or you have a quicker call on it.
Kontakt loop tune (
ZoneLoop.loopTuning) is parsed but never propagated to output formatsSummary
When converting an NKI from Kontakt 5+ to any output format (SFZ tested), the per-loop tuning value that the user set in Kontakt's mapping editor is silently dropped. Other loop parameters (start, end, crossfade, mode) are preserved correctly.
The value is read correctly from the binary and exposed via
ZoneLoop.getLoopTuning(), but no caller of that getter exists anywhere in the project.Reproducer
.nki.ConvertWithMoss(17.1.0) to SFZ:loop_start=,loop_end=,loop_crossfade=, but noloop_tune=opcode.Root cause (read direction)
format/ni/kontakt/type/kontakt5/ZoneLoop.javacorrectly parses the value:But the only place that builds an
ISampleLoopout of aZoneLoop—format/ni/kontakt/type/AbstractKontaktFormat.javalines 181-192 — never callsgetLoopTuning():A grep over the whole repo confirms
getLoopTuninghas zero call sites outside the declaring class.Root cause (write direction)
ISampleLoop(andDefaultSampleLoop) have notuningfield. SFZ writer (format/sfz/SfzCreator.java::createLoops) emits onlyloop_start,loop_end, optionalloop_crossfade.SfzOpcode.javahas noLOOP_TUNEconstant.So even if the read side were fixed, no writer would emit it.
Why this matters
The SFZ v2
loop_tuneopcode (and ARIA aliaslooptune) lets the loop region play at a different pitch than the attack/tail. This is a real-world feature heavily used in commercial Kontakt libraries to keep the looped sustain in tune when the original loop slice has a slightly different fundamental than the root key. Losing it on conversion forces users to re-discover and re-enter the value by hand for every zone — for a 127-zone bass instrument this is hours of work.Suggested fix
I sketched the minimal set of changes; happy to send it as a PR if the approach is acceptable. Open question on units (see below).
1.
core/model/ISampleLoop.javaAdd two methods, defaulting to 0 for backwards compatibility:
2.
core/model/implementation/DefaultSampleLoop.java3.
format/ni/kontakt/type/AbstractKontaktFormat.javaline 190Open question for maintainer: what is the unit of the raw
float loopTuningin the Kontakt 5 binary?AbstractKontaktFormat.calculateTuneandK1Tag.calculateTuneboth apply12 * log2(ratio)to the multiplicative tune values, returning semitones. ButloopTuningis read as a rawfloatwith no transform. If it is already in semitones (matching the calculateTune output), the multiplier above is correct (* 100to convert to cents). If it is in cents directly, drop the* 100. If it is a ratio like the other tune fields, the conversion is12 * log2(loopTuning) * 100. I do not have a clean way to verify this without a Kontakt-saved NKI plus a way to inspect what Kontakt itself displays for that zone — your call.4.
format/sfz/SfzOpcode.java5.
format/sfz/SfzCreator.java::createLoops(around line 420)Other writers (DecentSampler etc.) can opt in later as needed.
Scope notes
Kontakt5Format.writeNKMis currently// Not supportedanyway, so this only affects export to other formats.Happy to open a PR once the unit question above is resolved, or you have a quicker call on it.