From 30ea18d376becf6384cd4c624a9dd28912d679dc Mon Sep 17 00:00:00 2001 From: Hakaru Hirose Date: Tue, 30 Jun 2026 08:33:44 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix(kls):=20match=20real=20Dexed=20ScaleLev?= =?UTF-8?q?el/ScaleCurve=20=E2=80=94=20sign,=20exp=20scale,=20breakpoint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M2DX scaleKeyboardLevel and the dx7ref/dx7refmki C twins diverged from real Dexed dx7note.cc on three parity-invisible, shared Swift+C counts; the circular scaleLevelMatchesDEXED test (M2DX vs the equally-wrong twin) hid them. Port Dexed ScaleLevel/ScaleCurve verbatim into all three: - sign: -curves (0/1) now attenuate, +curves (2/3) boost (was inverted, KLS ran backwards) - exp: exp_scale_data[33]*depth*329>>15 (was kNlsTable*depth+1024>>11, ~8-15x too weak) - breakpoint: note - break_pt - 17 (was +21; whole curve was shifted +4 semitones) - linear: drop the +2048 rounding term; no internal 127 cap (clamp at the outlevel sum) Tests: add scaleKeyboardLevelMatchesRealDexedGoldens (hardcoded real-Dexed values, breaks the circular reference); re-baseline klsAtBreakPoint to the +17 hinge (note 56). MarkICalibrationTests.darkerSustain -> withKnownIssue: the correct KLS drops BASS 1's over-bright Modern centroid 685->180 Hz so the x0.6 'Mark I markedly darker' threshold no longer holds (the disputed di8 darkness is markI-ops-dac finding 2). 240 tests pass (1 known issue). Co-Authored-By: Claude Opus 4.8 (1M context) --- Sources/DX7Ref/dx7ref.c | 51 +++++++--------- Sources/DX7Ref/dx7refmki.c | 47 ++++++-------- Sources/M2DXCore/Tables/ScalingTable.swift | 61 +++++++++---------- .../M2DXCoreTests/MarkICalibrationTests.swift | 16 ++++- Tests/M2DXCoreTests/ReferenceTests.swift | 31 ++++++++++ Tests/M2DXCoreTests/TableTests.swift | 4 +- 6 files changed, 115 insertions(+), 95 deletions(-) diff --git a/Sources/DX7Ref/dx7ref.c b/Sources/DX7Ref/dx7ref.c index bc32d36..164b1f7 100644 --- a/Sources/DX7Ref/dx7ref.c +++ b/Sources/DX7Ref/dx7ref.c @@ -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 }; // ============================================================================ @@ -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; } // ============================================================================ diff --git a/Sources/DX7Ref/dx7refmki.c b/Sources/DX7Ref/dx7refmki.c index 1bf3479..7b1bfa5 100644 --- a/Sources/DX7Ref/dx7refmki.c +++ b/Sources/DX7Ref/dx7refmki.c @@ -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] = { @@ -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) { diff --git a/Sources/M2DXCore/Tables/ScalingTable.swift b/Sources/M2DXCore/Tables/ScalingTable.swift index bb69ddb..7fefddc 100644 --- a/Sources/M2DXCore/Tables/ScalingTable.swift +++ b/Sources/M2DXCore/Tables/ScalingTable.swift @@ -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) diff --git a/Tests/M2DXCoreTests/MarkICalibrationTests.swift b/Tests/M2DXCoreTests/MarkICalibrationTests.swift index 056b9c3..49d9a52 100644 --- a/Tests/M2DXCoreTests/MarkICalibrationTests.swift +++ b/Tests/M2DXCoreTests/MarkICalibrationTests.swift @@ -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") + } } } diff --git a/Tests/M2DXCoreTests/ReferenceTests.swift b/Tests/M2DXCoreTests/ReferenceTests.swift index b1443a4..cacb5d3 100644 --- a/Tests/M2DXCoreTests/ReferenceTests.swift +++ b/Tests/M2DXCoreTests/ReferenceTests.swift @@ -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)") diff --git a/Tests/M2DXCoreTests/TableTests.swift b/Tests/M2DXCoreTests/TableTests.swift index 0f48807..3dc97a3 100644 --- a/Tests/M2DXCoreTests/TableTests.swift +++ b/Tests/M2DXCoreTests/TableTests.swift @@ -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") } From 7e137de378ad9588b636913a874cba83581d4c4a Mon Sep 17 00:00:00 2001 From: Hakaru Hirose Date: Tue, 30 Jun 2026 08:37:54 +0900 Subject: [PATCH 2/2] =?UTF-8?q?docs(changelog):=20M2DX-Core=20v1.16.0=20?= =?UTF-8?q?=E2=80=94=20KLS=20real-Dexed=20fidelity=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 87c2d13..f58392b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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