Skip to content

Kontakt loop tune (ZoneLoop.loopTuning) is parsed but never propagated to output formats #139

@cjslickmusic-max

Description

@cjslickmusic-max

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

  1. Open Kontakt, load any multi-sample patch with looped zones.
  2. In the mapping editor, set a non-zero loop tune for one zone (e.g. -50 cents).
  3. Save as .nki.
  4. 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 ZoneLoopformat/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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions