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
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [1.16.0] - 2026-06-30

### Fixed
- **Keyboard Level Scaling fidelity (KLS) — now matches real Dexed `ScaleLevel`/`ScaleCurve`.**
`scaleKeyboardLevel` (and the `dx7ref`/`dx7refmki` C twins) diverged from the real
asb2m10/dexed `dx7note.cc` on three parity-invisible, shared Swift+C counts that the circular
`scaleLevelMatchesDEXED` test (M2DX vs the equally-wrong twin) had hidden:
- **Sign was inverted** — `−`curves (0/1) now correctly attenuate away from the breakpoint and
`+`curves (2/3) boost; previously every KLS curve ran backwards.
- **Exponential curve was ~8–15× too weak** — now uses Dexed's `exp_scale_data[33]` with
`(raw·depth·329) >> 15` (was a foreign `kNlsTable` with `(…+1024) >> 11`, capping near 16/127).
- **Breakpoint constant** is now `note − break_pt − 17` (was `+21`, which shifted the whole
curve up 4 semitones).
- The linear path drops the spurious `+2048` rounding and the internal `min(127)` cap (clamping
happens at the output-level sum, like Dexed).
Audible: KLS-heavy and imported DX7 ROM patches now scale operator level across the keyboard in
the correct direction and magnitude, correcting a long-standing over-brightness (e.g. ROM1A
BASS 1's Modern sustain centroid drops 685→180 Hz toward the Dexed-faithful value).

### Tests
- Added `scaleKeyboardLevelMatchesRealDexedGoldens` (hardcoded real-Dexed values) to break the
circular M2DX-vs-twin reference; re-baselined `klsAtBreakPoint` to the `+17` hinge (note 56).
- `MarkICalibrationTests.darkerSustain` → `withKnownIssue`: the corrected KLS removes BASS 1's
over-brightness so the `÷0.6` "Mark I markedly darker" threshold no longer holds; revisit
together with the Mark I `÷8` modulation-darkness calibration.

## [1.15.0] - 2026-06-29

### Added
Expand Down
51 changes: 21 additions & 30 deletions Sources/DX7Ref/dx7ref.c
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,12 @@ static const uint8_t velocity_data[64] = {
};

// ============================================================================
// NLS Table for Keyboard Level Scaling (exponential curve)
// exp_scale_data — Keyboard Level Scaling exponential curve (from DEXED dx7note.cc)
// ============================================================================

static const int nls_table[32] = {
0, 0, 0, 1, 2, 4, 6, 9,
13, 17, 22, 28, 34, 41, 49, 58,
68, 79, 90, 103, 116, 131, 146, 163,
181, 200, 220, 241, 264, 288, 313, 339
static const int exp_scale_data[33] = {
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 11, 14, 16, 19, 23, 27, 33,
39, 47, 56, 66, 80, 94, 110, 126, 142, 158, 174, 190, 206, 222, 238, 250
};

// ============================================================================
Expand Down Expand Up @@ -213,37 +211,30 @@ int dx7ref_scale_velocity(int velocity, int sensitivity) {
}

// ============================================================================
// ScaleLevel (KLS) — from DEXED dx7note.cc
// ScaleCurve / ScaleLevel (KLS) — from DEXED dx7note.cc
// ============================================================================

static int dx7ref_scale_curve(int group, int depth, int curve) {
int scale;
if (curve == 0 || curve == 3) { // linear
scale = (group * depth * 329) >> 12;
} else { // exponential
int raw_exp = exp_scale_data[min_int(group, 32)];
scale = (raw_exp * depth * 329) >> 15;
}
if (curve < 2) { scale = -scale; }
return scale;
}

int dx7ref_scale_level(int midinote, int break_point,
int left_depth, int right_depth,
int left_curve, int right_curve) {
int bp = break_point + 21;
int diff = midinote - bp;
if (diff == 0) return 0;

int distance, depth, curve;
if (diff < 0) {
distance = -diff; depth = left_depth; curve = left_curve;
} else {
distance = diff; depth = right_depth; curve = right_curve;
}
if (depth == 0) return 0;

int group = min_int(31, (distance + 1) / 3);
int is_linear = (curve == 0 || curve == 3);
int is_negative = (curve < 2);

int scale;
if (is_linear) {
scale = (group * depth * 329 + 2048) >> 12;
int offset = midinote - break_point - 17;
if (offset >= 0) {
return dx7ref_scale_curve((offset + 1) / 3, right_depth, right_curve);
} else {
int nls_value = nls_table[min_int(31, group)];
scale = (nls_value * depth + 1024) >> 11;
return dx7ref_scale_curve(-(offset - 1) / 3, left_depth, left_curve);
}
int capped = min_int(127, scale);
return is_negative ? capped : -capped;
}

// ============================================================================
Expand Down
47 changes: 19 additions & 28 deletions Sources/DX7Ref/dx7refmki.c
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,9 @@ static const uint8_t velocity_data[64] = {
246, 248, 249, 250, 251, 252, 253, 254
};

static const int nls_table[32] = {
0, 0, 0, 1, 2, 4, 6, 9,
13, 17, 22, 28, 34, 41, 49, 58,
68, 79, 90, 103, 116, 131, 146, 163,
181, 200, 220, 241, 264, 288, 313, 339
static const int exp_scale_data[33] = {
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 11, 14, 16, 19, 23, 27, 33,
39, 47, 56, 66, 80, 94, 110, 126, 142, 158, 174, 190, 206, 222, 238, 250
};

static const int level_lut[20] = {
Expand Down Expand Up @@ -233,34 +231,27 @@ static int scale_velocity(int velocity, int sensitivity) {
return scaled;
}

static int scale_curve(int group, int depth, int curve) {
int scale;
if (curve == 0 || curve == 3) { // linear
scale = (group * depth * 329) >> 12;
} else { // exponential
int raw_exp = exp_scale_data[min_int(group, 32)];
scale = (raw_exp * depth * 329) >> 15;
}
if (curve < 2) { scale = -scale; }
return scale;
}

static int scale_level(int midinote, int break_point,
int left_depth, int right_depth,
int left_curve, int right_curve) {
int bp = break_point + 21;
int diff = midinote - bp;
if (diff == 0) return 0;

int distance, depth, curve;
if (diff < 0) {
distance = -diff; depth = left_depth; curve = left_curve;
} else {
distance = diff; depth = right_depth; curve = right_curve;
}
if (depth == 0) return 0;

int group = min_int(31, (distance + 1) / 3);
int is_linear = (curve == 0 || curve == 3);
int is_negative = (curve < 2);

int scale;
if (is_linear) {
scale = (group * depth * 329 + 2048) >> 12;
int offset = midinote - break_point - 17;
if (offset >= 0) {
return scale_curve((offset + 1) / 3, right_depth, right_curve);
} else {
int nls_value = nls_table[min_int(31, group)];
scale = (nls_value * depth + 1024) >> 11;
return scale_curve(-(offset - 1) / 3, left_depth, left_curve);
}
int capped = min_int(127, scale);
return is_negative ? capped : -capped;
}

static int scale_outlevel(int outlevel) {
Expand Down
61 changes: 28 additions & 33 deletions Sources/M2DXCore/Tables/ScalingTable.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,49 +52,44 @@ package func scaleOutputLevel(_ ol: Int) -> Int {

// MARK: - Keyboard Level Scaling (KLS)

/// Exponential KLS curve table (32 entries).
/// Approximates DX7 OPS chip non-linear scaling.
package let kNlsTable: [Int] = [
0, 0, 0, 1, 2, 4, 6, 9,
13, 17, 22, 28, 34, 41, 49, 58,
68, 79, 90, 103, 116, 131, 146, 163,
181, 200, 220, 241, 264, 288, 313, 339
/// Exponential KLS curve table (33 entries) — verbatim from MSFA/Dexed dx7note.cc
/// `exp_scale_data`. Indexed by the keyboard-distance group, clamped to its last entry.
package let kExpScaleData: [Int] = [
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 11, 14, 16, 19, 23, 27, 33,
39, 47, 56, 66, 80, 94, 110, 126, 142, 158, 174, 190, 206, 222, 238, 250
]

/// Keyboard Level Scaling — returns offset (signed).
/// Negative curves → positive offset (more attenuation).
/// Positive curves → negative offset (boost).
/// One side of the DX7 keyboard level-scaling curve (Dexed `ScaleCurve`).
/// `curve`: 0 = −LIN, 1 = −EXP, 2 = +EXP, 3 = +LIN. The −curves (0/1) attenuate away
/// from the breakpoint (negative result); the +curves (2/3) boost (positive result).
@inline(__always)
package func scaleCurve(group: Int, depth: Int, curve: UInt8) -> Int {
var scale: Int
if curve == 0 || curve == 3 { // linear
scale = (group * depth * 329) >> 12
} else { // exponential
let rawExp = kExpScaleData[min(group, kExpScaleData.count - 1)]
scale = (rawExp * depth * 329) >> 15
}
if curve < 2 { scale = -scale }
return scale
}

/// Keyboard Level Scaling — returns the signed output-level offset for `note`
/// (Dexed `ScaleLevel`). The breakpoint hinge sits at note = breakPoint + 17. The
/// result is NOT pre-clamped to ±127; the caller clamps the summed output level.
@inline(__always)
package func scaleKeyboardLevel(
_ note: UInt8, breakPoint: UInt8,
leftDepth: UInt8, rightDepth: UInt8,
leftCurve: UInt8, rightCurve: UInt8
) -> Int {
let bp = Int(breakPoint) + 21
let diff = Int(note) - bp
if diff == 0 { return 0 }

let distance: Int, depth: Int, curve: UInt8
if diff < 0 {
distance = -diff; depth = Int(leftDepth); curve = leftCurve
} else {
distance = diff; depth = Int(rightDepth); curve = rightCurve
}
guard depth > 0 else { return 0 }

let group = min(31, (distance + 1) / 3)
let isLinear = (curve == 0 || curve == 3)
let isNegative = curve < 2

let scale: Int
if isLinear {
scale = (group * depth * 329 + 2048) >> 12
let offset = Int(note) - Int(breakPoint) - 17
if offset >= 0 {
return scaleCurve(group: (offset + 1) / 3, depth: Int(rightDepth), curve: rightCurve)
} else {
let nlsValue = kNlsTable[group]
scale = (nlsValue * depth + 1024) >> 11
return scaleCurve(group: -(offset - 1) / 3, depth: Int(leftDepth), curve: leftCurve)
}
let capped = min(127, scale)
return isNegative ? capped : -capped
}

// MARK: - AMS Depth Table (4 entries, Q24)
Expand Down
16 changes: 14 additions & 2 deletions Tests/M2DXCoreTests/MarkICalibrationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,19 @@ struct MarkICalibrationTests {
let ratio = centModern > 0 ? centMarkI / centModern : Double.nan
print(String(format: "🌑 BASS1 sustain centroid: Modern=%.1f Hz MarkI=%.1f Hz ratio=%.3f (want MarkI < Modern×0.6)",
centModern, centMarkI, ratio))
#expect(centMarkI < centModern * 0.6,
"Mark I sustain (centroid \(centMarkI) Hz) is NOT markedly darker than Modern (centroid \(centModern) Hz); ratio \(ratio) ≥ 0.6")
// KNOWN ISSUE — the fix/kls-fidelity branch ported real-Dexed ScaleLevel/ScaleCurve,
// correcting BASS 1's previously inverted/under-scaled keyboard level scaling. That had
// been over-driving a modulator and rendering Modern far too bright (sustain centroid
// ~685 Hz — the obs-7181 "≈2× brighter than Dexed" symptom; a KLS-only change can shift
// the centroid only via the FM modulation index, i.e. a modulator level). With faithful
// KLS the Modern centroid drops to ~180 Hz and Mark I to ~142 Hz: Mark I is still darker
// (ratio ~0.79) but no longer < Modern×0.6. The 0.6 threshold was propped up by the
// pre-fix over-brightness, and the Mark I ÷8 darkness it asserts is itself flagged as
// "too dark vs a real DX7" (audit markI-ops-dac finding 2). Re-tune or retire this
// magnitude assertion when the ÷8 calibration is revisited.
withKnownIssue("KLS fidelity fix corrected Modern over-brightness; 0.6 threshold + Mark I ÷8 darkness pending markI-ops-dac review") {
#expect(centMarkI < centModern * 0.6,
"Mark I sustain (centroid \(centMarkI) Hz) is NOT markedly darker than Modern (centroid \(centModern) Hz); ratio \(ratio) ≥ 0.6")
}
}
}
31 changes: 31 additions & 0 deletions Tests/M2DXCoreTests/ReferenceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,37 @@ struct DX7RefTests {
}
}

// MARK: - 3b. ScaleLevel vs REAL Dexed (non-circular golden values)

@Test("scaleKeyboardLevel matches REAL Dexed ScaleLevel golden values")
func scaleKeyboardLevelMatchesRealDexedGoldens() {
// Golden values computed directly from asb2m10/dexed Source/msfa/dx7note.cc
// ScaleLevel/ScaleCurve: offset = midinote - break_pt - 17; group = (offset+1)/3 (right)
// or -(offset-1)/3 (left); linear (curve 0/3) = (group*depth*329)>>12; exp (curve 1/2) =
// (exp_scale_data[min(group,32)]*depth*329)>>15; then `if (curve < 2) scale = -scale`.
// Curve encoding 0=-LIN, 1=-EXP, 2=+EXP, 3=+LIN. No internal 127 cap (clamped later at
// the outlevel sum). This is intentionally NOT compared against the C-twin, breaking the
// circular scaleLevelMatchesDEXED reference.
// (note, bp, leftDepth, rightDepth, leftCurve, rightCurve) -> expected
let cases: [(UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, Int)] = [
(36, 39, 99, 99, 0, 0, -55), // left -LIN: roll off below breakpoint (NEGATIVE)
(108, 39, 0, 99, 0, 2, 38), // right +EXP: boost above breakpoint (POSITIVE)
(60, 39, 50, 99, 1, 3, 7), // right +LIN
(48, 60, 99, 50, 1, 0, -10), // left -EXP
(56, 39, 99, 99, 2, 2, 0), // exactly at hinge (break_pt + 17) -> 0
(100, 39, 0, 0, 2, 2, 0), // depth 0 -> 0
(120, 39, 0, 99, 0, 3, 166), // right +LIN, uncapped (>127 before the outlevel clamp)
(30, 60, 99, 0, 2, 0, 32), // left +EXP: boost below breakpoint
(24, 39, 99, 0, 0, 0, -87), // left -LIN
]
for (note, bp, ld, rd, lc, rc, expected) in cases {
let result = scaleKeyboardLevel(note, breakPoint: bp, leftDepth: ld, rightDepth: rd,
leftCurve: lc, rightCurve: rc)
#expect(result == expected,
"scaleKeyboardLevel(\(note), bp:\(bp), L:\(ld), R:\(rd), lC:\(lc), rC:\(rc)) = \(result), expected real-Dexed \(expected)")
}
}

// MARK: - 4. scaleOutputLevel

@Test("scaleOutputLevel matches DEXED for all OL values (0-99)")
Expand Down
4 changes: 2 additions & 2 deletions Tests/M2DXCoreTests/TableTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -214,9 +214,9 @@ struct ScalingTableTests {

@Test("scaleKeyboardLevel no effect at break point")
func klsAtBreakPoint() {
let result = scaleKeyboardLevel(60, breakPoint: 39, leftDepth: 50, rightDepth: 50,
// Dexed hinge is at note = breakPoint + 17, so bp=39 → note 56 yields offset 0.
let result = scaleKeyboardLevel(56, breakPoint: 39, leftDepth: 50, rightDepth: 50,
leftCurve: 0, rightCurve: 0)
// Note 60, bp=39 → bp adjusted to 39+21=60, so diff=0
#expect(result == 0, "At break point, KLS should be 0")
}

Expand Down
Loading