diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d57d3d..63b7d8e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.18.1] - 2026-06-30 + +### Fixed (v1.17.0 fidelity-audit review follow-ups) +- **#96 held-note detune** — `DX7Voice.applyParams` reverted a sounding note's detune to the + pitch-independent constant on any live parameter edit (a snapshot version bump applies params to + all active voices), jumping the pitch versus a freshly-struck note. It now recomputes the per-note + `dexedDetuneFactor`, matching note-on. +- **#97 EG-bias ceiling** — the EG-bias level boost ignored the `min(127, …+klsOffset)` ceiling the + real `env.outlevel` uses, over-brightening key-scaled operators already saturated at the 127 OL + ceiling; the boost now routes through the same ceiling. +- **Pitch EG render-thread guard** — `PitchEG.process` guards the Float→Int32 `unit` narrowing + against a degenerate sample rate (0 / NaN) so it can't trap on the audio thread. + ## [1.18.0] - 2026-06-30 ### Added diff --git a/Sources/M2DXCore/Engine/DX7Operator.swift b/Sources/M2DXCore/Engine/DX7Operator.swift index ebd51a8..95d2ed3 100644 --- a/Sources/M2DXCore/Engine/DX7Operator.swift +++ b/Sources/M2DXCore/Engine/DX7Operator.swift @@ -81,9 +81,13 @@ package struct DX7Operator { // points), so a breath/AT/wheel/foot controller assigned to EG bias brightens + swells // the whole voice — the DX7's main expressive dynamics path. (OL-point scale calibratable.) if egBiasOL > 0 { + // Raise the operator level by egBiasOL OL points, through the SAME `min(127, …+klsOffset)` + // ceiling the real `env.outlevel` uses — so the bias never pushes a key-scaled operator + // past the 127 OL ceiling (it would otherwise over-brighten already-saturated notes). let biasedOL = min(99, outputLevel + Int(egBiasOL)) - let dOut = (scaleOutputLevel(biasedOL) - scaleOutputLevel(outputLevel)) << 5 - levelIn = levelIn &+ (Int32(dOut) << 16) + let base = min(127, scaleOutputLevel(outputLevel) + klsOffset) + let boosted = min(127, scaleOutputLevel(biasedOL) + klsOffset) + levelIn = levelIn &+ (Int32((boosted - base) << 5) << 16) } if amsDepth > 0 && lfoAmpMod > 0 { diff --git a/Sources/M2DXCore/Engine/DX7Voice.swift b/Sources/M2DXCore/Engine/DX7Voice.swift index 38db0e8..2e154ff 100644 --- a/Sources/M2DXCore/Engine/DX7Voice.swift +++ b/Sources/M2DXCore/Engine/DX7Voice.swift @@ -73,8 +73,11 @@ package struct PitchEG { @discardableResult mutating func process(sampleRate: Float) -> Bool { guard enabled, ix >= 0, ix < 4 else { return false } - // unit = N·(1<<24) / (21.3·Fs); inc = pitchenv_rate[rate] · unit. - let unit = Int32(64.0 * Float(1 << 24) / (21.3 * sampleRate) + 0.5) + // unit = N·(1<<24) / (21.3·Fs); inc = pitchenv_rate[rate] · unit. Guard the Float→Int32 + // narrowing so a degenerate sampleRate (0 → Inf, or NaN) can't trap on the render thread. + let unitF = Float(kBlockSize) * Float(1 << 24) / (21.3 * sampleRate) + 0.5 + guard unitF.isFinite else { return ix >= 0 && ix < 4 } + let unit = Int32(unitF) let inc = Int32(kPitchEnvRate[Int(min(99, rateForStage(ix)))]) &* unit // Stages 0-2 always advance; stage 3 (release) only after note-off. if ix < 3 || !down { @@ -692,8 +695,12 @@ package struct DX7Voice { withOp(opIndex) { op in op.setOutputLevel(params.dx7OutputLevel) op.ratio = params.ratio - op.detune = params.detune op.detuneCents = params.detuneCents + // #96: keep the per-note frequency-dependent detune on live edits to a HELD note + // (matching noteOn), not the pitch-independent params.detune. Fixed-freq ops cancel + // detune, so the constant value is harmless there. + op.detune = op.isFixedFreq ? params.detune + : dexedDetuneFactor(op.baseFrequency, detuneCents: params.detuneCents) op.env.setRates(params.dx7EgR0, params.dx7EgR1, params.dx7EgR2, params.dx7EgR3) op.env.setLevels(params.dx7EgL0, params.dx7EgL1, params.dx7EgL2, params.dx7EgL3) // Recompute frequency from the new ratio/detune so live edits affect a diff --git a/Tests/M2DXCoreTests/EGBiasTests.swift b/Tests/M2DXCoreTests/EGBiasTests.swift index fd6afc1..22ff144 100644 --- a/Tests/M2DXCoreTests/EGBiasTests.swift +++ b/Tests/M2DXCoreTests/EGBiasTests.swift @@ -31,6 +31,26 @@ struct EGBiasTests { "EG bias +50 OL = scaleOutputLevel(90)−(40) delta: got \(biased - base), expected \(expected)") } + /// levelIn of an op whose KLS pushes its output level past the 127 OL ceiling. + private func ceilingLevelIn(egBiasOL: Int32) -> Int32 { + var op = DX7Operator() + op.env.setLevels(99, 99, 70, 0) + op.klsOffset = 100 // large positive KLS → scaleOutputLevel(OL)+kls saturates at 127 + op.setOutputLevel(40) // 68 + 100 → clamped to 127 (already maxed) + op.env.noteOn() + for _ in 0..<10 { _ = op.env.getsample() } + op.updateGain(lfoAmpMod: 0, egBiasOL: egBiasOL) + return op.levelIn + } + + @Test("EG bias respects the 127 OL ceiling (klsOffset) — #97") + func egBiasRespectsCeiling() { + // The op already saturates the 127 ceiling via KLS, so a further EG-bias boost must add + // NOTHING — the real env path can't exceed 127 either. + #expect(ceilingLevelIn(egBiasOL: 50) == ceilingLevelIn(egBiasOL: 0), + "EG bias must not boost an operator already at the 127 OL ceiling") + } + @Test("EG bias of 0 is a no-op") func egBiasZeroNoOp() { var a = DX7Operator(); a.env.setLevels(99, 99, 70, 0); a.setOutputLevel(50); a.env.noteOn() diff --git a/Tests/M2DXCoreTests/ReferenceTests.swift b/Tests/M2DXCoreTests/ReferenceTests.swift index d3ed9af..07d0f53 100644 --- a/Tests/M2DXCoreTests/ReferenceTests.swift +++ b/Tests/M2DXCoreTests/ReferenceTests.swift @@ -143,6 +143,24 @@ struct DX7RefTests { #expect(bass > treble * 2.5, "C1 detune (\(bass)c) should be >2.5× C8 (\(treble)c)") } + @Test("Live param edit on a held note keeps the per-note frequency-dependent detune (#96)") + func liveEditKeepsPerNoteDetune() { + var voice = DX7Voice() + var snap = OperatorSnapshot() + snap.ratio = 1.0 + snap.detuneCents = 7 // max detune + snap.detune = Float(exp2(7.0 / 1200.0)) // the old pitch-independent constant factor + snap.dx7OutputLevel = 99 + voice.applyParams(snap, opIndex: 0) + voice.noteOn(24, velocity16: UInt16(100) << 9) // C1 — large frequency-dependent detune + let freshFreq = voice.ops.0.frequency + // Simulate a live param edit on the held note (snapshot version bump → applyParams on all voices). + voice.applyParams(snap, opIndex: 0) + let editedFreq = voice.ops.0.frequency + #expect(abs(editedFreq - freshFreq) < 0.001, + "live edit must keep the per-note detune (fresh=\(freshFreq), edited=\(editedFreq))") + } + // MARK: - 5. Exp2 Lookup @Test("exp2 lookup matches DEXED for representative input values")