From 1da408478744b9c6a6e53e586f3a1b3e2fd01cad Mon Sep 17 00:00:00 2001 From: Hakaru Hirose Date: Tue, 30 Jun 2026 08:56:11 +0900 Subject: [PATCH 1/2] docs(mark-i): recalibration plan + divisor A/B harness (markI-ops-dac findings 1-3) Investigation artifacts for the Mark I OPS forward-mod darkness recalibration (decision pending, by ear). Adds MarkIDivisorABTests (renders BASS 1 + E.PIANO 1 through the full SynthEngine at div4/5/6/8 + Modern, peak-normalized WAVs + sustain centroid) and docs/mark-i-recalibration-plan.md framing the coordinated fix (findings 1 output-scale, 2 divisor, 3 feedback), the KLS/darkerSustain interaction, A/B data, and a recommendation. No shipping code changed. Co-Authored-By: Claude Opus 4.8 (1M context) --- Tests/M2DXCoreTests/MarkIDivisorABTests.swift | 133 ++++++++++++++++++ docs/mark-i-recalibration-plan.md | 109 ++++++++++++++ 2 files changed, 242 insertions(+) create mode 100644 Tests/M2DXCoreTests/MarkIDivisorABTests.swift create mode 100644 docs/mark-i-recalibration-plan.md diff --git a/Tests/M2DXCoreTests/MarkIDivisorABTests.swift b/Tests/M2DXCoreTests/MarkIDivisorABTests.swift new file mode 100644 index 0000000..d9181d9 --- /dev/null +++ b/Tests/M2DXCoreTests/MarkIDivisorABTests.swift @@ -0,0 +1,133 @@ +// MarkIDivisorABTests.swift +// M2DX-Core — INVESTIGATION harness for the Mark I forward-modulation divisor +// recalibration (audit markI-ops-dac finding 2; see docs/mark-i-recalibration-plan.md). +// +// This is NOT a pass/fail test. It renders the two patches the Mark I divisor was +// originally calibrated against — ROM1A "BASS 1" (alg 15) and factory "E.PIANO 1" +// (alg 4, has feedback) — through the full production SynthEngine at several Mark I +// forward-mod divisors plus the Modern engine as the brightness reference, then: +// • prints raw peak + sustain spectral centroid (the brightness number), and +// • writes a PEAK-NORMALIZED (−3 dBFS) WAV per variant to /tmp/m2dx-marki-ab/ so an +// A/B listen compares TIMBRE, not loudness (loudness is a separate finding-1 axis, +// reported via the raw peak). +// +// Run: `swift test --filter renderMarkIDivisorAB` (clean-room: no GPL Dexed consulted). + +import Testing +import Foundation +@testable import M2DXCore + +@Suite("Mark I divisor A/B (investigation — writes WAVs, no assertions)") +struct MarkIDivisorABTests { + + private let sampleRate: Float = 48000 + private let total = 144000 // 3.0 s at 48 kHz + + /// Render a preset through the full SynthEngine at a chosen engine + Mark I divisor. + private func render(_ preset: DX7Preset, engine: FMEngine, divisor: Double?, + note: UInt8, vel: Int) -> [Float] { + let synth = SynthEngine() + synth.setSampleRate(sampleRate) + synth.setMasterVolume(1.0) + synth.setFMEngine(engine) + if let d = divisor { synth.setMarkIModDivisor(d) } + synth.loadDX7Preset(preset) + + let blk = 512 + var wL = [Float](repeating: 0, count: blk), wR = [Float](repeating: 0, count: blk) + wL.withUnsafeMutableBufferPointer { lp in wR.withUnsafeMutableBufferPointer { rp in + synth.render(into: lp.baseAddress!, bufferR: rp.baseAddress!, frameCount: blk) + }} + synth.sendMIDI(MIDIEvent(kind: .noteOn, data1: note, data2: UInt32(vel) << 9)) + + var out = [Float](repeating: 0, count: total) + var rr = [Float](repeating: 0, count: total) + out.withUnsafeMutableBufferPointer { op in rr.withUnsafeMutableBufferPointer { rp in + var off = 0 + while off < total { + let c = min(blk, total - off) + synth.render(into: op.baseAddress! + off, bufferR: rp.baseAddress! + off, frameCount: c) + off += c + } + }} + return out + } + + /// Spectral centroid (Hz) over `range` via a coarse 40 Hz-grid DFT (lower = darker). + private func centroid(_ samples: [Float], over range: Range) -> Double { + let sr = Double(sampleRate) + let n = range.count + var numer = 0.0, denom = 0.0, f = 40.0 + while f < 10000.0 { + var re = 0.0, im = 0.0 + let w = 2.0 * Double.pi * f / sr + for i in range { + let s = Double(samples[i]); let p = w * Double(i - range.lowerBound) + re += s * cos(p); im -= s * sin(p) + } + let mag2 = (re * re + im * im) / Double(n * n) + numer += f * mag2; denom += mag2; f += 40.0 + } + return denom > 0 ? numer / denom : 0 + } + + /// Minimal 16-bit mono PCM WAV writer (avoids a PresetLabKit test-target dependency). + private func writeWAV(_ samples: [Float], sampleRate: Int, to url: URL) throws { + var data = Data() + func le32(_ v: UInt32) { var x = v.littleEndian; withUnsafeBytes(of: &x) { data.append(contentsOf: $0) } } + func le16(_ v: UInt16) { var x = v.littleEndian; withUnsafeBytes(of: &x) { data.append(contentsOf: $0) } } + let dataSize = samples.count * 2 + data.append(contentsOf: Array("RIFF".utf8)); le32(UInt32(36 + dataSize)) + data.append(contentsOf: Array("WAVE".utf8)) + data.append(contentsOf: Array("fmt ".utf8)); le32(16); le16(1); le16(1) + le32(UInt32(sampleRate)); le32(UInt32(sampleRate * 2)); le16(2); le16(16) + data.append(contentsOf: Array("data".utf8)); le32(UInt32(dataSize)) + for s in samples { + let c = max(-1.0, min(1.0, s)) + le16(UInt16(bitPattern: Int16(c * 32767.0))) + } + try data.write(to: url) + } + + @Test("Render Mark I divisor A/B WAVs (BASS 1 + E.PIANO 1)") + func renderMarkIDivisorAB() throws { + let dir = URL(fileURLWithPath: "/tmp/m2dx-marki-ab") + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + + let bass1 = DX7SysExParser.parse(data: bankFromVoice(rom1aBass1Packed), bankName: "ROM1A")!.presets[0] + let epiano1 = DX7FactoryPresets.all[11] // ROM1A E.PIANO 1 (alg 4, feedback) + let patches: [(name: String, preset: DX7Preset)] = [("bass1", bass1), ("epiano1", epiano1)] + + // Modern = the brightness reference; Mark I at four divisors bracketing the + // audit-faithful ÷4 … the bit-parity ÷8. + let variants: [(name: String, engine: FMEngine, divisor: Double?)] = [ + ("modern", .modern, nil), + ("mki_div4", .markI, 4.0), + ("mki_div5", .markI, 5.0), + ("mki_div6", .markI, 6.0), + ("mki_div8", .markI, 8.0), + ] + + let note: UInt8 = 60, vel = 100 + let sustain = 19200..<43200 // 0.4 .. 0.9 s, same window as MarkICalibrationTests + let targetPeak: Float = 0.707 // −3 dBFS after normalization + + print("── Mark I divisor A/B (note 60, vel 100, peak-normalized to −3 dBFS) ──") + for (pname, preset) in patches { + for v in variants { + let raw = render(preset, engine: v.engine, divisor: v.divisor, note: note, vel: vel) + let peak = raw.map { abs($0) }.max() ?? 0 + let cent = centroid(raw, over: sustain) + let g = peak > 1e-9 ? targetPeak / peak : 1 + let norm = raw.map { $0 * g } + let url = dir.appendingPathComponent("\(pname)_\(v.name).wav") + try writeWAV(norm, sampleRate: Int(sampleRate), to: url) + let q12 = v.divisor != nil ? " Q12=\(Int((4096.0 / v.divisor!).rounded()))" : "" + let pp = pname.padding(toLength: 8, withPad: " ", startingAt: 0) + let vv = v.name.padding(toLength: 9, withPad: " ", startingAt: 0) + print(" \(pp) \(vv) centroid=\(Int(cent.rounded())) Hz rawPeak=\(String(format: "%.4f", peak))\(q12) → \(url.path)") + } + } + print("── A/B WAVs in \(dir.path) — listen Modern vs ÷4/÷5/÷6/÷8 ──") + } +} diff --git a/docs/mark-i-recalibration-plan.md b/docs/mark-i-recalibration-plan.md new file mode 100644 index 0000000..5353d78 --- /dev/null +++ b/docs/mark-i-recalibration-plan.md @@ -0,0 +1,109 @@ +# Mark I OPS recalibration — plan & A/B (markI-ops-dac findings 1–3) + +Status: **decision pending (by ear).** This doc frames the Mark I forward-modulation +("darkness") recalibration raised by the DX7 fidelity audit, with A/B render data, so the +final divisor can be chosen by ear against a real DX7 / Dexed reference. No code that ships +is changed yet. **Clean-room:** the project deliberately does not consult Dexed's GPL +`EngineMkI.cpp`; nothing here does either — the reference is your own ears + the audit's +reasoning + public DX7 OPS docs. + +Parent: `dx7-fidelity-audit-2026-06-30.md` (`markI-ops-dac` dimension, findings 1–3). +A/B harness: `Tests/M2DXCoreTests/MarkIDivisorABTests.swift` → `swift test --filter renderMarkIDivisorAB`. + +## Why this is open + +The audit found three **shared Swift+C, parity-invisible** issues in the Mark I (OPS) path, +all about *calibration*, not structure (the OPS pipeline is bit-exact to the `dx7refmki` twin): + +1. **(finding 1, HIGH)** Mark I carrier output is `result << 13` ≈ 2²⁶ — **4× (12 dB) hotter** + than Modern's 2²⁴ through the same `/2²⁸` normalization. +2. **(finding 2, HIGH)** The forward-mod divisor (`markIModScaleQ12`, default ÷8 = parity; + app default ÷5) makes Mark I **darker than a real DX7** — the audit argues the faithful + value is **÷4** (mod index = Modern ≈ 2π, since DEXED level-matches its two engines). +3. **(finding 3, MEDIUM)** Feedback re-injects the 4×-hot signal through the same shift as + Modern → **4× too strong** for every feedback algorithm except the +2-compensated 4/6/32. + +These three are **entangled**: the divisor (2) sets brightness, but the `<<13` output scale (1) +sets loudness *and* the feedback strength (3). Changing the divisor alone leaves the 12 dB +loudness step and the hot feedback. So this is a **coordinated recalibration**, not a one-liner. + +It also entangles with the **KLS fidelity fix (v1.16.0)**: that fix corrected an inverted/under- +scaled keyboard level scaling that had been over-driving a BASS 1 modulator, so Modern BASS 1 is +now **correctly darker** (sustain centroid 685 → 180 Hz). The old `MarkICalibrationTests` +`darkerSustain` assertion (`Mark I < Modern × 0.6`) was tuned to the *old over-bright* Modern and +no longer holds; it is currently `withKnownIssue`, to be resolved **together with** this decision. + +## Current state (code) + +| Knob | Where | Value | +|---|---|---| +| `markIModScaleQ12` | `DX7MarkI.swift:68` | **512 = ÷8** (bit-parity default; matches `dx7refmki`) | +| app `markIDivisor` | `M2DXAudioEngine.swift` (`setMarkIModDivisor`) | **÷5** default, range **2–16**, Pro "Mark I Depth" slider | +| carrier output | `DX7MarkI.swift` `mkiSin` | `result << 13` (≈2²⁶, 4× Modern) — *unchanged so far* | +| feedback | `DX7MarkI.swift` / `DX7Voice` | unscaled `(y0+y)>>(fbShift+1)`, +2 only on alg 4/6/32 — *unchanged* | +| default engine | `M2DXAudioEngine.swift:143` | **`.markI`** (every user hears this) | + +## A/B render data + +`note 60, vel 100, peak-normalized to −3 dBFS so you compare TIMBRE not loudness.` Sustain-window +spectral centroid (lower = darker); raw peak is the un-normalized loudness (finding-1 axis). + +| patch | Modern | ÷4 | ÷5 (app) | ÷6 | ÷8 (parity) | +|---|---|---|---|---|---| +| **BASS 1** (alg 15) centroid | 180 Hz | **160** | 152 | 148 | **142** | +| BASS 1 raw peak | 0.0871 | 0.0848 | 0.0824 | 0.0802 | 0.0773 | +| **E.PIANO 1** (alg 4) centroid | 280 Hz | 280 | 280 | 280 | 280 | +| E.PIANO 1 raw peak | 0.0993 | 0.0957 | 0.0957 | 0.0957 | 0.0957 | + +WAVs: `/tmp/m2dx-marki-ab/{bass1,epiano1}_{modern,mki_div4,mki_div5,mki_div6,mki_div8}.wav`. + +**What the data says** +- The divisor's brightness effect is **patch- and time-dependent.** On BASS 1 (modulation sustains) + it spans 142→160 Hz across ÷8→÷4 — a real but **compressed ~12%** range. On E.PIANO 1 the + modulators have decayed by the sustain window, so the divisor barely moves the *sustain* centroid + (its effect is in the **attack** transient instead) — **judge E.PIANO by ear over the whole note.** +- Even at the audit-"faithful" ÷4, BASS 1 Mark I (160 Hz) stays **darker than Modern** (180 Hz). + So ÷4 does **not** make Mark I identical to Modern — the OPS log-sin/exp quantization is inherently + a touch darker. The audit's "mod index = Modern" is approximate, not exact, in practice. +- The raw-peak column shows the divisor barely changes loudness (0.085→0.077). The 4×-hot **12 dB** + step (finding 1) is the `<<13` output scale vs Modern, a **separate** axis the A/B normalizes away. + +## Decisions to make + +1. **Forward-mod divisor** (the brightness knob): **÷4** (audit-faithful, brightest, closest to + Modern) · **÷5** (today's app default) · **÷6** · keep **÷8** (parity, darkest). The audible + spread is modest; pick by ear from the WAVs / on device. +2. **Finding 1 (4× hot output):** fix `mkiSin` `<<13 → <<11` (→2²⁴, level-matched to Modern) and + re-derive the divisor accordingly? This removes the 12 dB engine-switch jump and clipping/Maximizer + reliance, but **changes Mark I loudness** and must be done *with* the divisor (the A/B already + isolates timbre, so the chosen ÷ stays valid after a pure output re-scale). +3. **Finding 3 (4× hot feedback):** scale the feedback term like Modern and drop the alg-4/6/32 +2 + band-aid? Best done after (1) so one shift law serves both engines. +4. **`darkerSustain` test:** with corrected KLS + any of the above, "Mark I markedly darker than + Modern (÷0.6)" is the wrong assertion. Options: (a) re-tune to the chosen ÷'s measured ratio, + (b) weaken to "Mark I ≤ Modern" (still true at every ÷ for BASS 1), or (c) retire it and keep the + A/B harness as the calibration record. Decide once ÷ is fixed. + +## Recommendation + +- **Divisor:** lean **÷4** (or ÷5 if you prefer the current voicing) — Mark I is the *shipping default + engine*, so matching a real DX7's modulation index matters for everyone, and ÷4 is still audibly + "OPS-dark" vs Modern. But this is your ear's call against a real DX7/Dexed; the WAVs are there for it. +- **Do the coordinated fix, not just the divisor:** also `<<13→<<11` (finding 1) + feedback scaling + (finding 3), so loudness, brightness, and feedback are all faithful and the engine-switch level jump + goes away. Treat as one reviewed, RT-careful change with a fresh on-device A/B. +- **Test:** weaken `darkerSustain` to "Mark I ≤ Modern" (b) and keep `MarkIDivisorABTests` as the + living calibration record. + +## Implementation plan (once ÷ + scope are chosen) + +1. `DX7MarkI.swift`: set `markIModScaleQ12` default to the chosen value (÷4 → 1024); if doing finding 1, + change `mkiSin` `<<13 → <<11` and re-derive the divisor; mirror both in `dx7refmki.c`. Keep the app's + `setMarkIModDivisor` range/UI (recenter on the new default). +2. Finding 3: scale the feedback term (`DX7MarkI.swift` `computeFb*MkI`) consistently; remove the + alg-4/6/32 +2 special-case if (1) makes it unnecessary. +3. Tests: re-baseline `MarkICalibrationTests` (`darkerSustain` per the chosen option; `headroom` peak + threshold if loudness changed); the `dx7refmki` parity tests must stay green (Swift == C twin). +4. App: bump `M2DXAudioEngine` default divisor + UI range; new M2DX-Core release + pin bump; on-device A/B. + +Until then: v1.16.0 ships the KLS fix with Mark I unchanged (÷5), `darkerSustain` `withKnownIssue`. From 10abda364466a04274958e5adc76d287dfc63b81 Mon Sep 17 00:00:00 2001 From: Hakaru Hirose Date: Tue, 30 Jun 2026 09:12:18 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix(mark-i):=20close=20finding=202=20(?= =?UTF-8?q?=C3=B75=20maintained);=20darkerSustain=20=E2=86=92=20Mark=20I?= =?UTF-8?q?=20<=20Modern;=20env-gate=20A/B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An A/B by ear found ÷4-8 nearly indistinguishable (data: BASS 1 ~12% centroid spread, E.PIANO 1 divisor-insensitive), so the shipping Mark I forward-mod divisor stays ÷5 — markI-ops-dac finding 2 is closed as a low-impact calibration. Findings 1 (12 dB output) & 3 (feedback) remain deferred. - MarkICalibrationTests.darkerSustain: drop the stale ÷0.6 'markedly darker' threshold (an artifact of the pre-KLS-fix over-bright Modern); assert only Mark I < Modern. - MarkIDivisorABTests: env-gate behind M2DX_MARKI_AB so it no-ops in a normal swift test (it mutates the process-global markIModScaleQ12 and was racing the Mark I parity suite under the parallel runner); restore the ÷8 default on exit. - docs/mark-i-recalibration-plan.md: record the decision. 241 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../M2DXCoreTests/MarkICalibrationTests.swift | 37 +++++++------------ Tests/M2DXCoreTests/MarkIDivisorABTests.swift | 11 +++++- docs/mark-i-recalibration-plan.md | 25 ++++++++++--- 3 files changed, 43 insertions(+), 30 deletions(-) diff --git a/Tests/M2DXCoreTests/MarkICalibrationTests.swift b/Tests/M2DXCoreTests/MarkICalibrationTests.swift index 49d9a52..2e11f93 100644 --- a/Tests/M2DXCoreTests/MarkICalibrationTests.swift +++ b/Tests/M2DXCoreTests/MarkICalibrationTests.swift @@ -116,15 +116,16 @@ struct MarkICalibrationTests { "Mark I peak \(peakMarkI) exceeds 0.5 headroom (Modern peaks ~\(peakModern))") } - // (b) DARKER SUSTAIN — the whole musical point of Mark I: the OPS feeds forward - // modulation to the carrier phase 3 bits lower than MSFA (`markIModScale`, ÷8), - // so the modulation index collapses and the sustain tone approaches a pure sine - // (centroid markedly lower than Modern's). ÷8 was calibrated against real Dexed - // Mark I and confirmed by ear across BASS 1 (alg15) and E.PIANO 1 (alg4). - // - // Measured: Modern ≈ 685 Hz, Mark I ≈ 176 Hz (ratio ≈ 0.26) — near the ~131 Hz - // near-pure-sine target. Hard assertion (the earlier `withKnownIssue` gap is fixed). - @Test("Mark I BASS 1 sustain is markedly darker than Modern") + // (b) DARKER SUSTAIN — Mark I (OPS) is the darker/warmer engine: its sustain spectral + // centroid is lower than Modern's. We assert only the DIRECTION (Mark I < Modern), not a + // magnitude. How MUCH darker is set by the forward-mod divisor (app default ÷5), whose + // audible effect is small and patch-dependent — an A/B across ÷4…÷8 spans only ~12% on + // BASS 1 and is divisor-insensitive on E.PIANO 1 at sustain (the user confirmed ÷4–÷8 are + // nearly indistinguishable by ear), so it is a low-impact calibration left to the ear; see + // docs/mark-i-recalibration-plan.md (markI-ops-dac finding 2 — closed, ÷5 maintained). + // The earlier ×0.6 "markedly darker" threshold was an artifact of the pre-KLS-fix + // over-bright Modern (centroid ~685 Hz, now correctly ~180 Hz). + @Test("Mark I BASS 1 sustain is darker than Modern") func darkerSustain() { let modern = renderBASS1(engine: .modern) let markI = renderBASS1(engine: .markI) @@ -132,21 +133,9 @@ struct MarkICalibrationTests { let centModern = centroid(modern, over: sustain) let centMarkI = centroid(markI, over: sustain) 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)", + print(String(format: "🌑 BASS1 sustain centroid: Modern=%.1f Hz MarkI=%.1f Hz ratio=%.3f (want MarkI < Modern)", centModern, centMarkI, ratio)) - // 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") - } + #expect(centMarkI < centModern, + "Mark I sustain (centroid \(centMarkI) Hz) should be darker than Modern (centroid \(centModern) Hz); ratio \(ratio)") } } diff --git a/Tests/M2DXCoreTests/MarkIDivisorABTests.swift b/Tests/M2DXCoreTests/MarkIDivisorABTests.swift index d9181d9..5030b4f 100644 --- a/Tests/M2DXCoreTests/MarkIDivisorABTests.swift +++ b/Tests/M2DXCoreTests/MarkIDivisorABTests.swift @@ -11,7 +11,7 @@ // A/B listen compares TIMBRE, not loudness (loudness is a separate finding-1 axis, // reported via the raw peak). // -// Run: `swift test --filter renderMarkIDivisorAB` (clean-room: no GPL Dexed consulted). +// Run: `M2DX_MARKI_AB=1 swift test --filter renderMarkIDivisorAB` (clean-room: no GPL Dexed). import Testing import Foundation @@ -91,6 +91,15 @@ struct MarkIDivisorABTests { @Test("Render Mark I divisor A/B WAVs (BASS 1 + E.PIANO 1)") func renderMarkIDivisorAB() throws { + // Investigation harness: it mutates the process-global `markIModScaleQ12` (via + // setMarkIModDivisor), which races the Mark I parity suite under Swift Testing's parallel + // runner. Gate it behind an env var so a normal `swift test` is a no-op; render with: + // M2DX_MARKI_AB=1 swift test --filter renderMarkIDivisorAB + guard ProcessInfo.processInfo.environment["M2DX_MARKI_AB"] != nil else { + print("MarkIDivisorABTests skipped — set M2DX_MARKI_AB=1 to render the A/B WAVs") + return + } + defer { SynthEngine().setMarkIModDivisor(8.0) } // restore the global default (÷8) let dir = URL(fileURLWithPath: "/tmp/m2dx-marki-ab") try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) diff --git a/docs/mark-i-recalibration-plan.md b/docs/mark-i-recalibration-plan.md index 5353d78..72edfc1 100644 --- a/docs/mark-i-recalibration-plan.md +++ b/docs/mark-i-recalibration-plan.md @@ -1,6 +1,6 @@ # Mark I OPS recalibration — plan & A/B (markI-ops-dac findings 1–3) -Status: **decision pending (by ear).** This doc frames the Mark I forward-modulation +Status: **DECIDED 2026-06-30 — finding 2 closed (÷5 maintained); findings 1 & 3 deferred.** This doc frames the Mark I forward-modulation ("darkness") recalibration raised by the DX7 fidelity audit, with A/B render data, so the final divisor can be chosen by ear against a real DX7 / Dexed reference. No code that ships is changed yet. **Clean-room:** the project deliberately does not consult Dexed's GPL @@ -8,7 +8,22 @@ is changed yet. **Clean-room:** the project deliberately does not consult Dexed' reasoning + public DX7 OPS docs. Parent: `dx7-fidelity-audit-2026-06-30.md` (`markI-ops-dac` dimension, findings 1–3). -A/B harness: `Tests/M2DXCoreTests/MarkIDivisorABTests.swift` → `swift test --filter renderMarkIDivisorAB`. +A/B harness: `Tests/M2DXCoreTests/MarkIDivisorABTests.swift` → `M2DX_MARKI_AB=1 swift test --filter renderMarkIDivisorAB` (env-gated: it mutates the process-global divisor, so it is a no-op in a normal `swift test`). + +## Decision (2026-06-30) + +**Finding 2 (forward-mod divisor) — CLOSED, ÷5 maintained.** An A/B by ear (BASS 1 + E.PIANO 1, +peak-normalized) found ÷4 / ÷5 / ÷6 / ÷8 **nearly indistinguishable**, matching the data (BASS 1 +spans only ~12%, E.PIANO 1 is divisor-insensitive at sustain). The divisor's "darkness" is a +low-impact calibration, so the shipping default stays **÷5** — changing it is not worth a +recalibration of the default engine. `MarkICalibrationTests.darkerSustain` is weakened from the +(pre-KLS-fix, over-bright-Modern) `÷0.6` threshold to assert only the surviving true property +**`Mark I < Modern`**; `MarkIDivisorABTests` is kept as the living A/B record. + +**Findings 1 (4× / 12 dB hot output) & 3 (4× feedback) — DEFERRED.** These are the genuinely +audible Mark I issues (the A/B normalized loudness away, so they were not evaluated): the 12 dB +engine-switch level jump / clip-and-Maximizer reliance, and over-hot feedback. They are separate, +more structural, and tracked in the audit (`markI-ops-dac` findings 1 & 3) for a future, measured pass. ## Why this is open @@ -86,9 +101,9 @@ WAVs: `/tmp/m2dx-marki-ab/{bass1,epiano1}_{modern,mki_div4,mki_div5,mki_div6,mki ## Recommendation -- **Divisor:** lean **÷4** (or ÷5 if you prefer the current voicing) — Mark I is the *shipping default - engine*, so matching a real DX7's modulation index matters for everyone, and ÷4 is still audibly - "OPS-dark" vs Modern. But this is your ear's call against a real DX7/Dexed; the WAVs are there for it. +- **Divisor:** **÷5 maintained** (see the Decision at the top) — the A/B found ÷4…÷8 nearly + indistinguishable by ear, so the brightness knob is a low-impact calibration not worth changing + the shipping default. The bracketing data + WAVs remain for any future re-evaluation. - **Do the coordinated fix, not just the divisor:** also `<<13→<<11` (finding 1) + feedback scaling (finding 3), so loudness, brightness, and feedback are all faithful and the engine-switch level jump goes away. Treat as one reviewed, RT-careful change with a fresh on-device A/B.