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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 6 additions & 2 deletions Sources/M2DXCore/Engine/DX7Operator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
13 changes: 10 additions & 3 deletions Sources/M2DXCore/Engine/DX7Voice.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions Tests/M2DXCoreTests/EGBiasTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
18 changes: 18 additions & 0 deletions Tests/M2DXCoreTests/ReferenceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading