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

## [Unreleased]

## [1.17.0] - 2026-06-30

DX7 fidelity audit — engine/voice dimension fixes (verified against DEXED/MSFA source).

### Fixed
- **EG release force-kill removed (#92)** — `DX7Envelope` no longer guillotines the release after
a fixed ~2 s wall-clock (which truncated + clicked slow-release tails); the release runs to its
natural completion, bit-exactly matching the DEXED EG trace. Long releases are reclaimed by voice
stealing, not a wall-clock cap.
- **Frequency-dependent operator detune (#96)** — detune now follows DEXED's per-note curve
(~±17 c in the bass down to ~±3.5 c in the treble) instead of a pitch-independent constant ±7 c,
so detuned operator pairs beat at the correct rate (`dexedDetuneFactor`).
- **LFO speed→Hz from the DEXED `lfoSource` table (#94)** — replaces the pragmatic exponential whose
1.47 Hz floor at speed 0 made the DX7's slow vibrato / multi-second sweeps unreachable; now
0.0625 Hz at speed 0 up to ~49.3 Hz at speed 99 (`kLFOSpeedHz`).
- **Pitch EG rewritten to DEXED `PitchEnv` (#93)** — starts at L4 / sustains at L3 / releases to L4
(was a mis-rotated PL1/PL4/PL1), uses the non-linear `pitchenv_tab` (level 70 → 7.5 st, was 19.6),
and a constant per-block slope (was a fixed per-stage duration). Adds `kPitchEnvTab`/`kPitchEnvRate`.

### Added
- **Controller→EG-bias destination (#97)** — wheel/foot/breath/AT assigned to EG bias now raise the
operator output level in real time (the DX7's main breath/AT-controlled brightness/dynamics path),
routed through the real `scaleOutputLevel`. Was stored but never applied. With no controller
assigned the render path is byte-identical to v1.16.0. (OL-point scale is calibratable by ear.)

### Tests
- New coverage where the audit found none: `slowReleaseNotKilledAtTwoSeconds`,
`detuneMatchesDexedFrequencyDependent` (vs `dx7ref_osc_freq`), `lfoSpeedRange`, `PitchEGTests`,
`EGBiasTests`. 248 tests pass.

## [1.16.0] - 2026-06-30

### Fixed
Expand Down
17 changes: 5 additions & 12 deletions Sources/M2DXCore/Engine/DX7Envelope.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,10 @@ package struct DX7Envelope {

var srMultiplier: Int64 = 1 << 24 // Q24: (44100/sampleRate) × (1<<24)

var releaseBlockCount: Int = 0
var releaseTimeoutBlocks: Int = 1378 // ~2s at 44.1kHz

var isActive: Bool { ix >= 0 }

mutating func setSampleRate(_ sr: Float) {
srMultiplier = Int64(Double(44100.0) / Double(sr) * Double(1 << 24))
releaseTimeoutBlocks = Int(sr * 2.0) / kBlockSize
recalcCurrentInc()
}

Expand All @@ -52,7 +48,6 @@ package struct DX7Envelope {
mutating func noteOn() {
level = 0
down = true
releaseBlockCount = 0
advance(0)
}

Expand Down Expand Up @@ -96,13 +91,11 @@ package struct DX7Envelope {
}
}

if !down {
releaseBlockCount += 1
if releaseBlockCount >= releaseTimeoutBlocks {
level = 0; ix = -1
}
}

// No fixed 2 s wall-clock release kill (which truncated + clicked slow release tails,
// and which neither the real DX7 nor the DEXED C twin have): the release runs to its
// natural completion (advance(4) → ix = -1 at the L4 floor), bit-exactly matching the
// DEXED reference EG trace. Voice slots for long releases are reclaimed by voice
// stealing, not a wall-clock guillotine. (#92)
return level
}

Expand Down
17 changes: 16 additions & 1 deletion Sources/M2DXCore/Engine/DX7Operator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ package struct DX7Operator {
var frequency: Float = 440
var ratio: Float = 1.0
var detune: Float = 1.0
var detuneCents: Float = 0 // #96: DX7 detune param − 7 (−7…+7); per-note factor computed at noteOn
var outputLevel: Int = 99
var phase: Int32 = 0 // Q24 phase accumulator
var freq: Int32 = 0 // Q24 per-sample phase increment
Expand Down Expand Up @@ -41,6 +42,10 @@ package struct DX7Operator {

mutating func noteOn(baseFreq: Float) {
baseFrequency = baseFreq
// #96: DEXED applies a frequency-dependent operator detune (more cents in the bass, less
// in the treble), not a pitch-independent constant ±7c. Recompute the factor per note.
// (Fixed-freq ops get their frequency overridden right after note-on; detune cancels.)
detune = dexedDetuneFactor(baseFreq, detuneCents: detuneCents)
frequency = baseFreq * ratio * detune
updateFreq()
env.noteOn()
Expand All @@ -67,10 +72,20 @@ package struct DX7Operator {

/// Update gain from EG. Called once per block before compute.
@inline(__always)
mutating func updateGain(lfoAmpMod: Int32) {
mutating func updateGain(lfoAmpMod: Int32, egBiasOL: Int32 = 0) {
let egLevel = env.getsample()
levelIn = egLevel

// #97: controller→EG bias raises the operator output level in real time. Apply it as an
// exact level offset through the real scaleOutputLevel curve (= raising OL by `egBiasOL`
// 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 {
let biasedOL = min(99, outputLevel + Int(egBiasOL))
let dOut = (scaleOutputLevel(biasedOL) - scaleOutputLevel(outputLevel)) << 5
levelIn = levelIn &+ (Int32(dOut) << 16)
}

if amsDepth > 0 && lfoAmpMod > 0 {
let amod = Int32((Int64(lfoAmpMod) * Int64(amsDepth)) >> 24)
levelIn = levelIn &- amod
Expand Down
152 changes: 56 additions & 96 deletions Sources/M2DXCore/Engine/DX7Voice.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,23 +8,36 @@ import Darwin
/// Simple 4-stage pitch envelope generator that outputs semitones directly.
/// Operates in Float arithmetic at block rate (64 samples/block).
/// Level 50 = center (0 semitones), 0 = -48 semitones, 99 = +48 semitones.
/// DX7 pitch envelope — a faithful port of DEXED `PitchEnv` (pitchenv.cc). Seeds at L4,
/// ramps L4→L1→L2→L3 (sustains at L3 while held), releases to L4; level→pitch is the
/// non-linear `pitchenv_tab`, and rate sets a constant per-block slope. #93
package struct PitchEG {
var rates: (UInt8, UInt8, UInt8, UInt8) = (99, 99, 99, 99)
var levels: (UInt8, UInt8, UInt8, UInt8) = (50, 50, 50, 50)
/// -1 = idle, 0-3 = active stages
var stage: Int = -1
/// Current interpolated level (0-99 float)
var currentLevel: Float = 50.0
var targetLevel: Float = 50.0
var increment: Float = 0.0
/// -1 = idle, 0-3 = active stages, 4 = done (holds at L4).
var ix: Int = -1
/// DEXED PitchEnv `level_`: `pitchenv_tab[level] << 19` (1<<24 = one octave).
var level: Int32 = 0
var targetLevel: Int32 = 0
var rising: Bool = false
var down: Bool = false
var enabled: Bool = false

/// Current pitch offset in semitones (-48 to +48).
var semitones: Float {
(currentLevel - 50.0) / 49.0 * 48.0
/// Pitch offset in semitones: level / (1<<24) octaves × 12 (1 pitchenv_tab unit ≈ 0.375 st).
var semitones: Float { Float(level) / Float(1 << 24) * 12.0 }

@inline(__always)
private func tab(_ l: UInt8) -> Int32 { Int32(kPitchEnvTab[Int(min(99, l))]) << 19 }
@inline(__always)
private func levelForStage(_ s: Int) -> UInt8 {
switch s { case 0: return levels.0; case 1: return levels.1; case 2: return levels.2; default: return levels.3 }
}
@inline(__always)
private func rateForStage(_ s: Int) -> UInt8 {
switch s { case 0: return rates.0; case 1: return rates.1; case 2: return rates.2; default: return rates.3 }
}

/// DEXED PitchEnv::set + keydown(true): seed at L4, then advance(0). Starts at L4, NOT L1.
mutating func noteOn(
r0: UInt8, r1: UInt8, r2: UInt8, r3: UInt8,
l0: UInt8, l1: UInt8, l2: UInt8, l3: UInt8,
Expand All @@ -34,102 +47,46 @@ package struct PitchEG {
levels = (l0, l1, l2, l3)
enabled = r0 != 99 || r1 != 99 || r2 != 99 || r3 != 99 ||
l0 != 50 || l1 != 50 || l2 != 50 || l3 != 50
guard enabled else { stage = -1; return }

currentLevel = Float(l0) // Start at L0 (pre-attack level)
stage = 0
guard enabled else { ix = -1; level = 0; down = false; return }
level = tab(l3) // seed at L4
down = true
advanceStage(sampleRate: sampleRate)
advance(0)
}

/// DEXED keydown(false): advance(3), releasing toward L4.
mutating func noteOff(sampleRate: Float) {
guard enabled else { return }
guard enabled, down else { return }
down = false
if stage >= 0 && stage < 3 {
stage = 3
advanceStage(sampleRate: sampleRate)
}
advance(3)
}

private mutating func advanceStage(sampleRate: Float) {
guard stage >= 0 && stage < 4 else {
stage = -1
return
}
// DX7 Pitch EG stages:
// Stage 0: L0 → L1 at R0
// Stage 1: L1 → L2 at R1
// Stage 2: L2 → L3 at R2 (sustain while key held)
// Stage 3: L3 → L0 at R3 (release, return to pre-attack level)
let lvl: Float
let rate: UInt8
switch stage {
case 0: lvl = Float(levels.1); rate = rates.0
case 1: lvl = Float(levels.2); rate = rates.1
case 2: lvl = Float(levels.3); rate = rates.2
case 3: lvl = Float(levels.0); rate = rates.3
default: stage = -1; return
}
targetLevel = lvl

let blocksPerSecond = sampleRate / Float(kBlockSize)
if rate >= 99 {
// Instant: jump immediately, recurse to next stage
currentLevel = targetLevel
increment = 0.0
// Hold sustain stage when key is down
if stage == 2 && down { return }
stage += 1
if stage < 4 {
advanceStage(sampleRate: sampleRate)
} else {
stage = -1
currentLevel = 50.0
}
} else {
// Exponential time curve: rate 0 ≈ very slow, rate 98 ≈ fast
let timeSeconds = powf(10.0, Float(99 - Int(rate)) / 30.0) * 0.01
let totalBlocks = max(1.0, timeSeconds * blocksPerSecond)
increment = (targetLevel - currentLevel) / totalBlocks
}
@inline(__always)
private mutating func advance(_ newIx: Int) {
ix = newIx
guard ix < 4 else { return }
targetLevel = tab(levelForStage(ix))
rising = targetLevel > level
}

/// Call once per 64-sample block. Returns true while envelope is active.
/// Advance once per render block (DEXED PitchEnv::getsample). `sampleRate` is the
/// (possibly oversampled) render rate, so wall-clock pitch-EG time stays constant.
@discardableResult
mutating func process(sampleRate: Float) -> Bool {
guard enabled && stage >= 0 else { return false }

// Sustain: hold at L3 while key is held
if stage == 2 && down && increment == 0.0 { return true }

if increment == 0.0 {
// Already at target — advance to next stage
if stage == 2 && down { return true }
stage += 1
if stage >= 4 { stage = -1; currentLevel = 50.0; return false }
advanceStage(sampleRate: sampleRate)
return stage >= 0
}

// Interpolate toward target
currentLevel += increment
let reached: Bool
if increment > 0 {
reached = currentLevel >= targetLevel
} else {
reached = currentLevel <= targetLevel
}

if reached {
currentLevel = targetLevel
increment = 0.0
if stage == 2 && down { return true }
stage += 1
if stage >= 4 { stage = -1; currentLevel = 50.0; return false }
advanceStage(sampleRate: sampleRate)
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)
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 {
if rising {
level = level &+ inc
if level >= targetLevel { level = targetLevel; advance(ix + 1) }
} else {
level = level &- inc
if level <= targetLevel { level = targetLevel; advance(ix + 1) }
}
}

return stage >= 0
return ix >= 0 && ix < 4
}
}

Expand All @@ -149,6 +106,7 @@ package struct DX7Voice {
var pitchBendFactor: Float = 1.0
var slotId: Int = 0
var lfoAmpMod: Int32 = 0
var egBiasOL: Int32 = 0 // #97: controller→EG-bias OL boost, set per render block
var feedbackShiftValue: Int = 16
var engineMode: FMEngine = .modern

Expand Down Expand Up @@ -244,9 +202,10 @@ package struct DX7Voice {
@inline(__always)
mutating func updateGains() {
let lfaMod = lfoAmpMod
ops.0.updateGain(lfoAmpMod: lfaMod); ops.1.updateGain(lfoAmpMod: lfaMod)
ops.2.updateGain(lfoAmpMod: lfaMod); ops.3.updateGain(lfoAmpMod: lfaMod)
ops.4.updateGain(lfoAmpMod: lfaMod); ops.5.updateGain(lfoAmpMod: lfaMod)
let bias = egBiasOL
ops.0.updateGain(lfoAmpMod: lfaMod, egBiasOL: bias); ops.1.updateGain(lfoAmpMod: lfaMod, egBiasOL: bias)
ops.2.updateGain(lfoAmpMod: lfaMod, egBiasOL: bias); ops.3.updateGain(lfoAmpMod: lfaMod, egBiasOL: bias)
ops.4.updateGain(lfoAmpMod: lfaMod, egBiasOL: bias); ops.5.updateGain(lfoAmpMod: lfaMod, egBiasOL: bias)
}

/// Render one block, dispatching to the selected FM engine.
Expand Down Expand Up @@ -721,6 +680,7 @@ package struct DX7Voice {
op.setOutputLevel(params.dx7OutputLevel)
op.ratio = params.ratio
op.detune = params.detune
op.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
1 change: 1 addition & 0 deletions Sources/M2DXCore/Engine/ParameterSnapshot.swift
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ public struct OperatorSnapshot: Sendable {
public var level: Float = 1.0
public var ratio: Float = 1.0
public var detune: Float = 1.0
public var detuneCents: Float = 0 // #96: DX7 detune param − 7 (−7…+7) for per-note frequency-dependent detune
public var feedback: Float = 0.0
public var egR0: Float = 99, egR1: Float = 75, egR2: Float = 50, egR3: Float = 50
public var egL0: Float = 1.0, egL1: Float = 0.8, egL2: Float = 0.7, egL3: Float = 0.0
Expand Down
20 changes: 14 additions & 6 deletions Sources/M2DXCore/Engine/SynthEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ public final class SynthEngine: @unchecked Sendable {
var atPitchDepth: Float = 0
var controllerAmpMod: Float = 1.0
var lfoAMDNorm: Float = 0
var egBiasOL: Int32 = 0 // #97: controller→EG-bias output-level boost (0…99 OL points)
}

/// SplitMix64 — small, fast, deterministic PRNG used for LFO Sample-and-Hold.
Expand Down Expand Up @@ -422,7 +423,7 @@ public final class SynthEngine: @unchecked Sendable {

public func setOperatorDetune(_ opIndex: Int, cents: Float) {
guard opIndex >= 0, opIndex < kNumOperators else { return }
withShadowOp(opIndex) { $0.detune = powf(2.0, cents / 1200.0) }
withShadowOp(opIndex) { $0.detune = powf(2.0, cents / 1200.0); $0.detuneCents = cents }
bumpVersion()
}

Expand Down Expand Up @@ -794,6 +795,7 @@ public final class SynthEngine: @unchecked Sendable {
s.level = op.normalizedLevel
s.ratio = op.frequencyRatio
s.detune = powf(2.0, op.detuneCents / 1200.0)
s.detuneCents = op.detuneCents
// Feedback stored as Float(fb)/7.0 — only on the feedback operator (op0)
s.feedback = isFeedbackOp ? Float(voiceFeedback) / 7.0 : 0
s.dx7OutputLevel = op.outputLevel
Expand Down Expand Up @@ -1076,12 +1078,12 @@ public final class SynthEngine: @unchecked Sendable {

// MARK: - LFO

/// DX7 LFO speed (0…99) → frequency in Hz. Calibrated to the documented DX7
/// LFO range: 0.0625 Hz at speed 0, ≈47 Hz at speed 99 (exponential).
/// DX7 LFO speed (0…99) → frequency in Hz — the verbatim DEXED `lfoSource` table
/// (asb2m10/dexed Source/msfa/lfo.cc): 0.0625 Hz at speed 0 up to ~49.3 Hz at speed 99.
/// Replaces the old pragmatic exponential (1.47·e^(0.035·speed)), whose 1.47 Hz floor at
/// speed 0 put the DX7's slow vibrato / multi-second sweeps (sub-0.1 Hz) out of reach. #94
package static func lfoSpeedToHz(_ speed: UInt8) -> Float {
// Usable vibrato range: ~1.5 Hz at speed 0, ~5 Hz at the default 35, ~47 Hz
// at 99. (Pragmatic exponential — pending exact DX7/Dexed reference data.)
1.47 * expf(Float(speed) * 0.0350)
kLFOSpeedHz[Int(min(99, speed))]
}

private func lfoWaveformValue(_ phase: Float, waveform: UInt8, slotIdx: Int = 0) -> Float {
Expand Down Expand Up @@ -1172,6 +1174,11 @@ public final class SynthEngine: @unchecked Sendable {
let bAmp = Float(slot.breathAmp) / 99.0 * breathDepth
let aAmp = Float(slot.aftertouchAmp) / 99.0 * aftertouchDepth
slotMods[s].controllerAmpMod = 1.0 - (wAmp + fAmp + bAmp + aAmp) * 0.5
// #97: EG-bias destination — each controller's EG-bias range (0-99) × its depth
// raises the operator output level by that many OL points (summed, capped at 99).
let egb = Float(slot.wheelEGBias) * modWheelDepth + Float(slot.footEGBias) * footDepth
+ Float(slot.breathEGBias) * breathDepth + Float(slot.aftertouchEGBias) * aftertouchDepth
slotMods[s].egBiasOL = Int32(min(99.0, egb))
}

for i in 0..<maxV {
Expand Down Expand Up @@ -1275,6 +1282,7 @@ public final class SynthEngine: @unchecked Sendable {
lfoAmpModVal = lfoAmpModVal &+ Int32(volAtten)
}
voicesDX7[i].lfoAmpMod = lfoAmpModVal
voicesDX7[i].egBiasOL = voicesDX7[i].detached ? 0 : slotMods[s].egBiasOL // #97
}

for i in 0..<maxV {
Expand Down
Loading
Loading