diff --git a/CHANGELOG.md b/CHANGELOG.md index f58392b..42cacfc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Sources/M2DXCore/Engine/DX7Envelope.swift b/Sources/M2DXCore/Engine/DX7Envelope.swift index af08d11..098c75c 100644 --- a/Sources/M2DXCore/Engine/DX7Envelope.swift +++ b/Sources/M2DXCore/Engine/DX7Envelope.swift @@ -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() } @@ -52,7 +48,6 @@ package struct DX7Envelope { mutating func noteOn() { level = 0 down = true - releaseBlockCount = 0 advance(0) } @@ -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 } diff --git a/Sources/M2DXCore/Engine/DX7Operator.swift b/Sources/M2DXCore/Engine/DX7Operator.swift index 3a930dd..ebd51a8 100644 --- a/Sources/M2DXCore/Engine/DX7Operator.swift +++ b/Sources/M2DXCore/Engine/DX7Operator.swift @@ -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 @@ -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() @@ -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 diff --git a/Sources/M2DXCore/Engine/DX7Voice.swift b/Sources/M2DXCore/Engine/DX7Voice.swift index 86215c7..6f39522 100644 --- a/Sources/M2DXCore/Engine/DX7Voice.swift +++ b/Sources/M2DXCore/Engine/DX7Voice.swift @@ -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, @@ -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 } } @@ -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 @@ -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. @@ -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 diff --git a/Sources/M2DXCore/Engine/ParameterSnapshot.swift b/Sources/M2DXCore/Engine/ParameterSnapshot.swift index 6c580d3..f835cd1 100644 --- a/Sources/M2DXCore/Engine/ParameterSnapshot.swift +++ b/Sources/M2DXCore/Engine/ParameterSnapshot.swift @@ -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 diff --git a/Sources/M2DXCore/Engine/SynthEngine.swift b/Sources/M2DXCore/Engine/SynthEngine.swift index 4910310..fb33a96 100644 --- a/Sources/M2DXCore/Engine/SynthEngine.swift +++ b/Sources/M2DXCore/Engine/SynthEngine.swift @@ -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. @@ -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() } @@ -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 @@ -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 { @@ -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.. = { /// Pitch Mod Sensitivity: how much LFO affects pitch (in semitones at max PMD). /// PMS 0 = no effect, PMS 7 = ±4 semitones. package let kPMSDepth: [Float] = [0, 0.1, 0.2, 0.4, 0.7, 1.0, 2.0, 4.0] + +// MARK: - DX7 LFO Speed → Hz (DEXED lfoSource table, 100 entries) + +/// LFO frequency in Hz per DX7 speed param (0…99) — verbatim from DEXED/MSFA +/// `lfoSource[]` (asb2m10/dexed Source/msfa/lfo.cc). 0.0625 Hz at speed 0 up to +/// ~49.3 Hz at speed 99. #94 +package let kLFOSpeedHz: [Float] = [ + 0.062541, 0.125031, 0.312393, 0.437120, 0.624610, + 0.750694, 0.936330, 1.125302, 1.249609, 1.436782, + 1.560915, 1.752081, 1.875117, 2.062494, 2.247191, + 2.374451, 2.560492, 2.686728, 2.873976, 2.998950, + 3.188013, 3.369840, 3.500175, 3.682224, 3.812065, + 4.000800, 4.186202, 4.310716, 4.501260, 4.623209, + 4.814636, 4.930480, 5.121901, 5.315191, 5.434783, + 5.617346, 5.750431, 5.946717, 6.062811, 6.248438, + 6.431695, 6.564264, 6.749460, 6.868132, 7.052186, + 7.250580, 7.375719, 7.556294, 7.687577, 7.877738, + 7.993605, 8.181967, 8.372405, 8.504848, 8.685079, + 8.810573, 8.986341, 9.122423, 9.300595, 9.500285, + 9.607994, 9.798158, 9.950249, 10.117361, 11.251125, + 11.384335, 12.562814, 13.676149, 13.904338, 15.092062, + 16.366612, 16.638935, 17.869907, 19.193858, 19.425019, + 20.833333, 21.034918, 22.502250, 24.003841, 24.260068, + 25.746653, 27.173913, 27.578599, 29.052876, 30.693677, + 31.191516, 32.658393, 34.317090, 34.674064, 36.416606, + 38.197097, 38.550501, 40.387722, 40.749796, 42.625746, + 44.326241, 44.883303, 46.772685, 48.590865, 49.261084, +] + +// MARK: - DX7 Pitch EG (DEXED pitchenv.cc) + +/// Pitch EG level (0…99) → signed pitch, DEXED `pitchenv_tab` (int8). The EG `level_` is +/// `pitchenv_tab[level] << 19`; 1 unit ≈ 0.375 st (1<<24 = one octave). Non-linear: gentle +/// in the middle, steep at the extremes. Center is index 50 (= 0). #93 +package let kPitchEnvTab: [Int] = [ + -128, -116, -104, -95, -85, -76, -68, -61, -56, -52, -49, -46, -43, + -41, -39, -37, -35, -33, -32, -31, -30, -29, -28, -27, -26, -25, + -24, -23, -22, -21, -20, -19, -18, -17, -16, -15, -14, -13, -12, -11, -10, + -9, -8, -7, -6, -5, -4, -3, -2, -1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, + 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, + 28, 29, 30, 31, 32, 33, 34, 35, 38, 40, 43, 46, 49, 53, 58, 65, 73, + 82, 92, 103, 115, 127, +] + +/// Pitch EG rate (0…99) → increment multiplier, DEXED `pitchenv_rate`. The per-block +/// increment is `pitchenv_rate[rate] · unit`, with `unit = 64·(1<<24)/(21.3·Fs)`. #93 +package let kPitchEnvRate: [Int] = [ + 1, 2, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7, 8, 8, 9, 9, 10, 10, 11, 11, 12, + 12, 13, 13, 14, 14, 15, 16, 16, 17, 18, 18, 19, 20, 21, 22, 23, 24, + 25, 26, 27, 28, 30, 31, 33, 34, 36, 37, 38, 39, 41, 42, 44, 46, 47, + 49, 51, 53, 54, 56, 58, 60, 62, 64, 66, 68, 70, 72, 74, 76, 79, 82, + 85, 88, 91, 94, 98, 102, 106, 110, 115, 120, 125, 130, 135, 141, 147, + 153, 159, 165, 171, 178, 185, 193, 202, 211, 232, 243, 254, 255, +] diff --git a/Sources/M2DXCore/Tables/ScalingTable.swift b/Sources/M2DXCore/Tables/ScalingTable.swift index 7fefddc..f595e96 100644 --- a/Sources/M2DXCore/Tables/ScalingTable.swift +++ b/Sources/M2DXCore/Tables/ScalingTable.swift @@ -128,6 +128,20 @@ package func fixedFreqHz(coarse: UInt8, fine: UInt8) -> Float { return powf(10.0, Float(Int(coarse) & 3) + Float(fine) / 100.0) } +// MARK: - Operator Detune (frequency-dependent, DEXED dx7note.cc) + +/// DX7 operator detune as a per-note frequency multiplier (DEXED `dx7ref_osc_freq`). +/// In the Q24 log2 domain DEXED does `logfreq += (0.0209·exp(-0.396·log2 freq)/7)·logfreq·c` +/// with `c = detune − 7` (−7…+7), giving ~±17 cents in the bass down to ~±3.5 cents in the +/// treble — NOT the pitch-independent constant ±7c M2DX used before. #96 +@inline(__always) +package func dexedDetuneFactor(_ freq: Float, detuneCents: Float) -> Float { + guard detuneCents != 0, freq > 0 else { return 1.0 } + let lf = log2f(freq) // = logfreq / (1<<24) + let detuneRatio = 0.0209 * expf(-0.396 * lf) / 7.0 + return exp2f(detuneRatio * lf * detuneCents) +} + // MARK: - Unison Detune /// Symmetric unison detune factor for voice `index` of `count`, spread to ±`detuneCents`. diff --git a/Tests/M2DXCoreTests/EGBiasTests.swift b/Tests/M2DXCoreTests/EGBiasTests.swift new file mode 100644 index 0000000..fd6afc1 --- /dev/null +++ b/Tests/M2DXCoreTests/EGBiasTests.swift @@ -0,0 +1,43 @@ +// EGBiasTests.swift +// M2DX-Core — controller→EG-bias destination (#97). The fields/setters existed but the +// value was never read in synthesis, so breath/AT/wheel/foot → EG bias did nothing. + +import Testing +@testable import M2DXCore + +@Suite("EG Bias (#97)") +struct EGBiasTests { + + /// levelIn of an op held at sustain after one updateGain block, for a given EG-bias OL boost. + private func sustainLevelIn(egBiasOL: Int32) -> Int32 { + var op = DX7Operator() + op.env.setRates(99, 99, 99, 99) + op.env.setLevels(99, 99, 70, 0) + op.setOutputLevel(40) // base OL 40 + op.env.noteOn() + for _ in 0..<10 { _ = op.env.getsample() } // settle into the held sustain + op.updateGain(lfoAmpMod: 0, egBiasOL: egBiasOL) + return op.levelIn + } + + @Test("EG bias raises the operator level by the exact scaleOutputLevel delta") + func egBiasRaisesLevel() { + let base = sustainLevelIn(egBiasOL: 0) + let biased = sustainLevelIn(egBiasOL: 50) // +50 OL → base 40 → 90 + #expect(biased > base, "EG bias must raise the operator level (was inert before #97)") + // The boost is exactly the OL-domain difference routed through the real scaleOutputLevel. + let expected = Int32((scaleOutputLevel(90) - scaleOutputLevel(40)) << 5) << 16 + #expect(biased - base == expected, + "EG bias +50 OL = scaleOutputLevel(90)−(40) delta: got \(biased - base), expected \(expected)") + } + + @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() + var b = DX7Operator(); b.env.setLevels(99, 99, 70, 0); b.setOutputLevel(50); b.env.noteOn() + for _ in 0..<5 { _ = a.env.getsample(); _ = b.env.getsample() } + a.updateGain(lfoAmpMod: 0, egBiasOL: 0) + b.updateGain(lfoAmpMod: 0) // default egBiasOL = 0 + #expect(a.levelIn == b.levelIn, "egBiasOL 0 must be byte-identical to no bias") + } +} diff --git a/Tests/M2DXCoreTests/EnvelopeTests.swift b/Tests/M2DXCoreTests/EnvelopeTests.swift index 589c1cd..fe02c73 100644 --- a/Tests/M2DXCoreTests/EnvelopeTests.swift +++ b/Tests/M2DXCoreTests/EnvelopeTests.swift @@ -67,6 +67,28 @@ struct DX7EnvelopeTests { #expect(finallyInactive, "Envelope should eventually become inactive after noteOff") } + @Test("Slow release rings past 2s — no wall-clock kill, runs to natural completion (#92)") + func slowReleaseNotKilledAtTwoSeconds() { + var env = DX7Envelope() + env.setSampleRate(44100) + env.setRates(99, 99, 99, 20) // R4=20: a slow release (tens of seconds to the floor) + env.setLevels(99, 99, 99, 0) + env.setOutputLevel(99) + env.noteOn() + for _ in 0..<300 { _ = env.getsample() } // settle into sustain + #expect(env.isActive, "should be sustaining before note-off") + + env.noteOff() + // Run ~2.5 s of blocks — past the old fixed 2 s force-kill. + let blocks = Int(44100.0 * 2.5) / kBlockSize + for _ in 0.. Float { + var eg = PitchEG() + eg.noteOn(r0: 99, r1: 99, r2: 99, r3: 99, + l0: level, l1: level, l2: level, l3: level, sampleRate: 44100) + eg.process(sampleRate: 44100) + return eg.semitones + } + + @Test("Level→pitch is the non-linear DEXED pitchenv_tab, not a linear ramp") + func nonLinearCurve() { + // DEXED pitchenv_tab[70] = 20 → 20 × 0.375 st ≈ 7.5 st. The old linear formula + // ((level−50)/49×48) gave 19.6 st — ~2.6× too sensitive mid-range. + #expect(abs(steadySemitones(70) - 7.5) < 0.3, "level 70 → ~7.5 st (pitchenv_tab), not 19.6 (linear)") + #expect(abs(steadySemitones(50) - 0.0) < 0.02, "level 50 → 0 st (center)") + #expect(abs(steadySemitones(99) - 47.6) < 0.6, "level 99 → ~47.6 st (max)") + #expect(abs(steadySemitones(0) - (-48.0)) < 0.6, "level 0 → ~−48 st (min)") + } + + @Test("Pitch EG starts at L4 (not L1) — DEXED level rotation") + func startsAtL4() { + // L1=99, L2=L3=L4=50, slow R1. DEXED seeds at L4 (=50 → 0 st) and ramps UP toward L1. + // The old code seeded at L1 (=99 → ~48 st). + var eg = PitchEG() + eg.noteOn(r0: 30, r1: 99, r2: 99, r3: 99, + l0: 99, l1: 50, l2: 50, l3: 50, sampleRate: 44100) + #expect(abs(eg.semitones) < 1.0, + "pitch EG must START at L4=50 (≈0 st), not L1=99 (≈48 st)") + } + + @Test("Pitch EG sustains at L3 while held, releases toward L4 on note-off") + func sustainAtL3ReleaseToL4() { + // L1=L2=99, L3=60, L4=50. Held: should settle/sustain at L3 (60 → >0 st). After + // note-off: should head toward L4 (50 → 0 st). + var eg = PitchEG() + eg.noteOn(r0: 99, r1: 99, r2: 99, r3: 30, + l0: 99, l1: 99, l2: 60, l3: 50, sampleRate: 44100) + for _ in 0..<200 { eg.process(sampleRate: 44100) } // settle into sustain + let sustain = eg.semitones + #expect(sustain > 1.0, "held pitch EG should sustain at L3=60 (>0 st), got \(sustain)") + eg.noteOff(sampleRate: 44100) + for _ in 0..<5000 { eg.process(sampleRate: 44100) } // release toward L4 + #expect(abs(eg.semitones) < 1.0, "after release should approach L4=50 (≈0 st), got \(eg.semitones)") + } +} diff --git a/Tests/M2DXCoreTests/ReferenceTests.swift b/Tests/M2DXCoreTests/ReferenceTests.swift index cacb5d3..d3ed9af 100644 --- a/Tests/M2DXCoreTests/ReferenceTests.swift +++ b/Tests/M2DXCoreTests/ReferenceTests.swift @@ -3,6 +3,7 @@ // Compares M2DX Swift engine functions against DEXED C reference implementations. import Testing +import Foundation @testable import M2DXCore import DX7Ref @@ -115,6 +116,33 @@ struct DX7RefTests { } } + // MARK: - 4b. Operator detune (frequency-dependent, #96) + + @Test("Operator detune is frequency-dependent, matching DEXED dx7ref_osc_freq (#96)") + func detuneMatchesDexedFrequencyDependent() { + let notes: [UInt8] = [24, 36, 48, 60, 69, 84, 96, 108] // C1 … C8 + let detunes = [0, 1, 4, 7, 10, 13, 14] // DX7 param 0-14 (7 = center) + for note in notes { + let freq = kMIDIFreqLUT[Int(note)] + for d in detunes { + // M2DX production detune (cents) via the per-note factor used at note-on. + let factor = dexedDetuneFactor(freq, detuneCents: Float(d - 7)) + let m2dxCents = log2(Double(factor)) * 1200.0 + // DEXED reference: logfreq(detune) − logfreq(center) in cents (ratio mode). + let lfDet = Double(dx7ref_osc_freq(Int32(note), 0, 0, 0, Int32(d))) + let lfCtr = Double(dx7ref_osc_freq(Int32(note), 0, 0, 0, 7)) + let refCents = (lfDet - lfCtr) / Double(1 << 24) * 1200.0 + #expect(abs(m2dxCents - refCents) < 0.4, + "detune note=\(note) param=\(d): M2DX \(m2dxCents)c vs DEXED \(refCents)c (Δ=\(m2dxCents - refCents))") + } + } + // Frequency-dependence: max detune is much larger in the bass than the treble. The old + // pitch-independent constant ±7c would give equal values and fail this. + let bass = abs(log2(Double(dexedDetuneFactor(kMIDIFreqLUT[24], detuneCents: 7))) * 1200) + let treble = abs(log2(Double(dexedDetuneFactor(kMIDIFreqLUT[108], detuneCents: 7))) * 1200) + #expect(bass > treble * 2.5, "C1 detune (\(bass)c) should be >2.5× C8 (\(treble)c)") + } + // MARK: - 5. Exp2 Lookup @Test("exp2 lookup matches DEXED for representative input values")