From 4693f247c338e8005179d43f1cfefb9d0f1c3f1b Mon Sep 17 00:00:00 2001 From: Hakaru Hirose Date: Sat, 3 Oct 2026 03:55:32 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix(eg-bias):=20follow=20the=20DX7=20?= =?UTF-8?q?=E2=80=94=20AMS-gated=20hold-down=20instead=20of=20an=20all-ope?= =?UTF-8?q?rator=20boost=20(M2DX=20#163)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The #97 EG-bias destination raised the output level of all six operators by range × controller. On the DX7, EG bias acts only on operators with AMS: the range holds them down at the controller's minimum and the controller releases them to their programmed level (DX7/DX100 manuals: range 0 = EG bias off, 99 = greatest variation; AMS 3 is a complete override, AMS 1-2 partial). With the boost, a fast breath rise also brightened the modulators, which gave wind sounds such as SAX[BC1] a sharp tongue attack (M2DX #163). EG bias now computes an attenuation range/99 × (1 − controller) per controller with range > 0 (the most open controller wins, as in Dexed) and joins the LFO amplitude-modulation path (max with LFO AMD), so AMS selects the operators and weights the depth. The per-operator OL-boost plumbing (egBiasOL) is removed. No smoothing is added; Dexed also applies CC2 per block. Reference measurements (Dexed 1.0.1 AU, Mark I): AMS 0 unaffected; AMS 3 silent at breath 0, -1 dB of open at breath 127; modulator AMS darkens at rest and brightens with breath; no CC smoothing. Dexed silences AMS operators at range 0 and keeps range 13 near silent at full breath, which contradicts the manuals, so range follows the manuals. Tests: EGBiasTests rewritten for both engines — range 0 bit-identical at any breath value, AMS 0 untouched, full breath bit-identical to no bias, range sets the hold-down depth (13 moderate, 99 > 50 dB), monotonic breath, AMS weighting, most open controller wins. 354 tests / 63 suites pass in Debug and Release. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 13 ++ Sources/M2DXCore/Engine/DX7Operator.swift | 17 +-- Sources/M2DXCore/Engine/DX7Voice.swift | 8 +- Sources/M2DXCore/Engine/SynthEngine.swift | 25 ++-- Tests/M2DXCoreTests/EGBiasTests.swift | 160 ++++++++++++++-------- docs/API.md | 8 +- 6 files changed, 145 insertions(+), 86 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e55171..189ed68 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.24.0] - 2026-10-03 + +### Changed +- **EG bias follows the DX7 (M2DX #163).** A controller's EG-bias range (wheel, foot, + breath, aftertouch) now acts only on operators with AMS, weighted by their AMS, through + the same amplitude-modulation path as the LFO. At the controller's minimum those operators + are held down by range/99; at its maximum they play at their programmed level. Range 0 is + EG bias off. With several EG-bias controllers the most open one applies. This replaces + the #97 behaviour, which raised the output level of all six operators: a fast breath rise + brightened the modulators as well and gave wind sounds a sharp attack. Sounds that use EG + bias now respond as on the DX7 and can sound different; with every EG-bias range at 0 the + output is unchanged. + ## [1.23.0] - 2026-09-30 ### Added diff --git a/Sources/M2DXCore/Engine/DX7Operator.swift b/Sources/M2DXCore/Engine/DX7Operator.swift index 9c1aeea..833702e 100644 --- a/Sources/M2DXCore/Engine/DX7Operator.swift +++ b/Sources/M2DXCore/Engine/DX7Operator.swift @@ -158,25 +158,12 @@ package struct DX7Operator { /// Update gain from EG. Called once per block before compute. @inline(__always) - mutating func updateGain(lfoAmpMod: Int32, egBiasOL: Int32 = 0) { + mutating func updateGain(lfoAmpMod: Int32) { let wasActive = env.isActive let egLevel = env.getsample() levelIn = wasActive && releaseReferenceOL != nil ? releaseLevel(egLevel) : 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 { - // 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 base = min(127, scaleOutputLevel(outputLevel) + klsOffset) - let boosted = min(127, scaleOutputLevel(biasedOL) + klsOffset) - levelIn = levelIn &+ (Int32((boosted - base) << 5) << 16) - } - + // Amplitude modulation (LFO AMD, controller EG bias #163) reaches only operators with AMS. 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 27a8950..afc3799 100644 --- a/Sources/M2DXCore/Engine/DX7Voice.swift +++ b/Sources/M2DXCore/Engine/DX7Voice.swift @@ -109,7 +109,6 @@ 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 var markIModScaleQ12: Int32 = kMarkIDefaultModScaleQ12 @@ -277,10 +276,9 @@ package struct DX7Voice { @inline(__always) mutating func updateGains() { let lfaMod = lfoAmpMod - 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) + 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) } /// Render one block, dispatching to the selected FM engine. diff --git a/Sources/M2DXCore/Engine/SynthEngine.swift b/Sources/M2DXCore/Engine/SynthEngine.swift index 5dcc49f..782849b 100644 --- a/Sources/M2DXCore/Engine/SynthEngine.swift +++ b/Sources/M2DXCore/Engine/SynthEngine.swift @@ -71,7 +71,9 @@ 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) + /// Controller→EG-bias attenuation in LFO-AMD units (0…1), applied only to operators with + /// AMS through the amplitude-modulation path (M2DX #163). 0 = no EG bias. + var egBiasAtten: Float = 0 } /// SplitMix64 — small, fast, deterministic PRNG used for LFO Sample-and-Hold. @@ -1540,11 +1542,17 @@ 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)) + // EG bias (DX7 semantics, M2DX #163): a controller whose EG-bias range is above 0 + // holds the operators that have AMS down by range/99 at its minimum and releases them + // to their programmed level at its maximum. Range 0 means EG bias off. When several + // controllers have EG bias, the most open one wins, as in Dexed. The attenuation uses + // the LFO amplitude-modulation path, so AMS selects the operators and sets the depth. + var egAtten: Float = 1, egBiasOn = false + if slot.wheelEGBias > 0 { egBiasOn = true; egAtten = min(egAtten, Float(min(slot.wheelEGBias, 99)) / 99 * (1 - min(1, max(0, modWheelDepth)))) } + if slot.footEGBias > 0 { egBiasOn = true; egAtten = min(egAtten, Float(min(slot.footEGBias, 99)) / 99 * (1 - min(1, max(0, footDepth)))) } + if slot.breathEGBias > 0 { egBiasOn = true; egAtten = min(egAtten, Float(min(slot.breathEGBias, 99)) / 99 * (1 - min(1, max(0, breathDepth)))) } + if slot.aftertouchEGBias > 0 { egBiasOn = true; egAtten = min(egAtten, Float(min(slot.aftertouchEGBias, 99)) / 99 * (1 - min(1, max(0, aftertouchDepth)))) } + slotMods[s].egBiasAtten = egBiasOn ? egAtten : 0 } for i in 0.. SynthEngine { + let engine = SynthEngine() + engine.setFMEngine(fm) + engine.setMasterVolume(0.5) + engine.setAlgorithm(31) + for i in 0..<6 { + engine.setOperatorDX7OutputLevel(i, level: i == 0 ? 99 : 0) + engine.setOperatorRatio(i, ratio: 1) + engine.setOperatorDX7EGRates(i, r1: 99, r2: 99, r3: 99, r4: 99) + engine.setOperatorDX7EGLevels(i, l1: 99, l2: 99, l3: 99, l4: 0) + engine.setOperatorAmpModSensitivity(i, value: i == 0 ? ams : 0) + } + engine.setBreathEGBias(range) + engine.setAftertouchEGBias(aftertouchRange) + _ = render(engine, blocks: 1) + return engine + } + + private func controller(_ engine: SynthEngine, _ cc: UInt8, _ value7: UInt32) { + let v = value7 & 0x7F + engine.sendMIDI(MIDIEvent(kind: .controlChange, data1: cc, data2: (v << 25) | (v << 18) | (v << 11) | (v << 4) | (v >> 3))) + } + + private func render(_ engine: SynthEngine, blocks: Int = 64) -> [Float] { + var left = [Float](repeating: 0, count: blocks * 64), right = left + left.withUnsafeMutableBufferPointer { l in + right.withUnsafeMutableBufferPointer { r in + engine.render(into: l.baseAddress!, bufferR: r.baseAddress!, frameCount: l.count) + } + } + return left + } + + private func held(engine fm: FMEngine, ams: UInt8, range: UInt8, breath: UInt32, aftertouchRange: UInt8 = 0) -> [Float] { + let engine = make(engine: fm, ams: ams, range: range, aftertouchRange: aftertouchRange) + controller(engine, 2, breath) + engine.sendMIDI(MIDIEvent(kind: .noteOn, data1: 60, data2: UInt32(100) << 9)) + _ = render(engine, blocks: 16) + return render(engine, blocks: 64) + } + + /// RMS (dB) of the held note after the breath value settles. + private func level(engine fm: FMEngine, ams: UInt8, range: UInt8, breath: UInt32, aftertouchRange: UInt8 = 0) -> Float { + let x = held(engine: fm, ams: ams, range: range, breath: breath, aftertouchRange: aftertouchRange) + let mean = x.reduce(0) { $0 + $1 * $1 } / Float(x.count) + return 10 * log10(mean + 1e-20) + } + + @Test("range 0 is EG bias off: output is bit-identical at any breath value", arguments: [FMEngine.modern, .markI]) + func rangeZeroIsOff(engine: FMEngine) { + #expect(held(engine: engine, ams: 3, range: 0, breath: 0) == held(engine: engine, ams: 3, range: 0, breath: 127)) + } + + @Test("operators without AMS are not affected", arguments: [FMEngine.modern, .markI]) + func amsZeroUnaffected(engine: FMEngine) { + #expect(held(engine: engine, ams: 0, range: 99, breath: 0) == held(engine: engine, ams: 0, range: 0, breath: 0)) + } + + @Test("full breath releases the operator to its programmed level", arguments: [FMEngine.modern, .markI]) + func fullBreathIsProgrammedLevel(engine: FMEngine) { + #expect(held(engine: engine, ams: 3, range: 99, breath: 127) == held(engine: engine, ams: 3, range: 0, breath: 0)) + } + + @Test("no breath holds an AMS 3 operator down; the range sets how far", arguments: [FMEngine.modern, .markI]) + func rangeSetsDepth(engine: FMEngine) { + let open = level(engine: engine, ams: 3, range: 0, breath: 0) + let r13 = level(engine: engine, ams: 3, range: 13, breath: 0) + let r50 = level(engine: engine, ams: 3, range: 50, breath: 0) + let r99 = level(engine: engine, ams: 3, range: 99, breath: 0) + #expect(open - r13 > 3 && open - r13 < 20, "range 13 is a moderate hold-down (got \(open - r13) dB)") + #expect(r13 > r50 && r50 > r99, "a larger range holds the operator further down") + #expect(open - r99 > 50, "range 99 with AMS 3 nearly silences the operator at rest (got \(open - r99) dB)") + } + + @Test("breath opens the operator gradually", arguments: [FMEngine.modern, .markI]) + func breathIsMonotonic(engine: FMEngine) { + let b0 = level(engine: engine, ams: 3, range: 99, breath: 0) + let b64 = level(engine: engine, ams: 3, range: 99, breath: 64) + let b127 = level(engine: engine, ams: 3, range: 99, breath: 127) + #expect(b0 < b64 && b64 < b127) + } + + @Test("AMS weights the depth: AMS 1 is held down less than AMS 3", arguments: [FMEngine.modern, .markI]) + func amsWeighting(engine: FMEngine) { + let open = level(engine: engine, ams: 1, range: 0, breath: 0) + let ams1 = level(engine: engine, ams: 1, range: 99, breath: 0) + let ams3 = level(engine: engine, ams: 3, range: 99, breath: 0) + #expect(open - ams1 > 1, "AMS 1 must still respond") + #expect(ams1 > ams3, "AMS 1 is held down less than AMS 3") + } - /// 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)") - } - - /// 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() - 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") + @Test("with two EG-bias controllers, the more open one wins", arguments: [FMEngine.modern, .markI]) + func mostOpenControllerWins(engine: FMEngine) { + // Breath fully open, aftertouch at rest with its own EG-bias range: the operator stays open. + let both = level(engine: engine, ams: 3, range: 99, breath: 127, aftertouchRange: 99) + let breathOnly = level(engine: engine, ams: 3, range: 99, breath: 127) + #expect(abs(both - breathOnly) < 0.01) } } diff --git a/docs/API.md b/docs/API.md index c872d3c..e27e288 100644 --- a/docs/API.md +++ b/docs/API.md @@ -150,16 +150,16 @@ All operator methods take `opIndex: Int` (0–5, maps to OP6–OP1 in DX7 conven |--------|-------|-------------| | `setWheelPitch(_ v:)` | 0–99 | Mod wheel → pitch depth | | `setWheelAmp(_ v:)` | 0–99 | Mod wheel → amplitude depth | -| `setWheelEGBias(_ v:)` | 0–99 | Mod wheel → EG bias depth | +| `setWheelEGBias(_ v:)` | 0–99 | Mod wheel → EG bias range (DX7: holds AMS operators down by range/99 at the controller minimum; 0 = off) | | `setFootPitch(_ v:)` | 0–99 | Foot controller → pitch | | `setFootAmp(_ v:)` | 0–99 | Foot controller → amplitude | -| `setFootEGBias(_ v:)` | 0–99 | Foot controller → EG bias | +| `setFootEGBias(_ v:)` | 0–99 | Foot controller → EG bias range (DX7: holds AMS operators down by range/99 at the controller minimum; 0 = off) | | `setBreathPitch(_ v:)` | 0–99 | Breath controller → pitch | | `setBreathAmp(_ v:)` | 0–99 | Breath controller → amplitude | -| `setBreathEGBias(_ v:)` | 0–99 | Breath controller → EG bias | +| `setBreathEGBias(_ v:)` | 0–99 | Breath controller → EG bias range (DX7: holds AMS operators down by range/99 at the controller minimum; 0 = off) | | `setAftertouchPitch(_ v:)` | 0–99 | Aftertouch → pitch | | `setAftertouchAmp(_ v:)` | 0–99 | Aftertouch → amplitude | -| `setAftertouchEGBias(_ v:)` | 0–99 | Aftertouch → EG bias | +| `setAftertouchEGBias(_ v:)` | 0–99 | Aftertouch → EG bias range (DX7: holds AMS operators down by range/99 at the controller minimum; 0 = off) | ### Multi-Timbral From 37eafb59d048ef48d73976867a676ac27a6f2f47 Mon Sep 17 00:00:00 2001 From: Hakaru Hirose Date: Sat, 3 Oct 2026 04:02:04 +0900 Subject: [PATCH 2/2] fix(eg-bias): add controller shares instead of taking the least attenuation (M2DX #163) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up. Taking the minimum attenuation across EG-bias controllers let the smallest range win at rest: wheel range 10 plus breath range 99 played nearly open without breath. Each controller's share range/99 × (1 − controller) now adds (capped at a full hold-down), so every EG-bias controller must open. The comment no longer claims to follow Dexed for this or for the range mapping (the manuals are used). Tests: controllerSharesAdd (an open breath adds nothing while aftertouch keeps its share; both at rest stay held down) and combinesWithLFOAmd (LFO AMD cannot lift an operator EG bias holds down). 355 tests / 63 suites pass in Debug and Release. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- Sources/M2DXCore/Engine/SynthEngine.swift | 22 +++++++++------- Tests/M2DXCoreTests/EGBiasTests.swift | 32 ++++++++++++++++++----- 3 files changed, 39 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 189ed68..3048309 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 breath, aftertouch) now acts only on operators with AMS, weighted by their AMS, through the same amplitude-modulation path as the LFO. At the controller's minimum those operators are held down by range/99; at its maximum they play at their programmed level. Range 0 is - EG bias off. With several EG-bias controllers the most open one applies. This replaces + EG bias off. With several EG-bias controllers their shares add, so each one must open. This replaces the #97 behaviour, which raised the output level of all six operators: a fast breath rise brightened the modulators as well and gave wind sounds a sharp attack. Sounds that use EG bias now respond as on the DX7 and can sound different; with every EG-bias range at 0 the diff --git a/Sources/M2DXCore/Engine/SynthEngine.swift b/Sources/M2DXCore/Engine/SynthEngine.swift index 782849b..6985a36 100644 --- a/Sources/M2DXCore/Engine/SynthEngine.swift +++ b/Sources/M2DXCore/Engine/SynthEngine.swift @@ -1543,16 +1543,18 @@ public final class SynthEngine: @unchecked Sendable { let aAmp = Float(slot.aftertouchAmp) / 99.0 * aftertouchDepth slotMods[s].controllerAmpMod = 1.0 - (wAmp + fAmp + bAmp + aAmp) * 0.5 // EG bias (DX7 semantics, M2DX #163): a controller whose EG-bias range is above 0 - // holds the operators that have AMS down by range/99 at its minimum and releases them - // to their programmed level at its maximum. Range 0 means EG bias off. When several - // controllers have EG bias, the most open one wins, as in Dexed. The attenuation uses - // the LFO amplitude-modulation path, so AMS selects the operators and sets the depth. - var egAtten: Float = 1, egBiasOn = false - if slot.wheelEGBias > 0 { egBiasOn = true; egAtten = min(egAtten, Float(min(slot.wheelEGBias, 99)) / 99 * (1 - min(1, max(0, modWheelDepth)))) } - if slot.footEGBias > 0 { egBiasOn = true; egAtten = min(egAtten, Float(min(slot.footEGBias, 99)) / 99 * (1 - min(1, max(0, footDepth)))) } - if slot.breathEGBias > 0 { egBiasOn = true; egAtten = min(egAtten, Float(min(slot.breathEGBias, 99)) / 99 * (1 - min(1, max(0, breathDepth)))) } - if slot.aftertouchEGBias > 0 { egBiasOn = true; egAtten = min(egAtten, Float(min(slot.aftertouchEGBias, 99)) / 99 * (1 - min(1, max(0, aftertouchDepth)))) } - slotMods[s].egBiasAtten = egBiasOn ? egAtten : 0 + // holds the operators that have AMS down by range/99 at its minimum and releases its + // share as it rises; at its maximum it adds nothing. Range 0 means EG bias off. Shares + // from several controllers add (capped at a full hold-down), so every EG-bias + // controller must open for the operators to reach their programmed level. The + // attenuation joins the LFO amplitude-modulation path, so AMS selects the operators + // and weights the depth. + var egAtten: Float = 0 + if slot.wheelEGBias > 0 { egAtten += Float(min(slot.wheelEGBias, 99)) / 99 * (1 - min(1, max(0, modWheelDepth))) } + if slot.footEGBias > 0 { egAtten += Float(min(slot.footEGBias, 99)) / 99 * (1 - min(1, max(0, footDepth))) } + if slot.breathEGBias > 0 { egAtten += Float(min(slot.breathEGBias, 99)) / 99 * (1 - min(1, max(0, breathDepth))) } + if slot.aftertouchEGBias > 0 { egAtten += Float(min(slot.aftertouchEGBias, 99)) / 99 * (1 - min(1, max(0, aftertouchDepth))) } + slotMods[s].egBiasAtten = min(1, egAtten) } for i in 0.. ams3, "AMS 1 is held down less than AMS 3") } - @Test("with two EG-bias controllers, the more open one wins", arguments: [FMEngine.modern, .markI]) - func mostOpenControllerWins(engine: FMEngine) { - // Breath fully open, aftertouch at rest with its own EG-bias range: the operator stays open. - let both = level(engine: engine, ams: 3, range: 99, breath: 127, aftertouchRange: 99) - let breathOnly = level(engine: engine, ams: 3, range: 99, breath: 127) - #expect(abs(both - breathOnly) < 0.01) + @Test("EG-bias shares from several controllers add", arguments: [FMEngine.modern, .markI]) + func controllerSharesAdd(engine: FMEngine) { + let open = level(engine: engine, ams: 3, range: 0, breath: 0) + // Breath fully open, aftertouch at rest with range 13: aftertouch still holds its share down. + let breathOpenATRest = level(engine: engine, ams: 3, range: 99, breath: 127, aftertouchRange: 13) + let atOnly = level(engine: engine, ams: 3, range: 0, breath: 0, aftertouchRange: 13) + #expect(abs(breathOpenATRest - atOnly) < 0.01, "the open breath adds nothing; aftertouch keeps its share") + #expect(open - atOnly > 3) + // Both at rest: a small aftertouch range cannot open a breath patch (review of M2DX #163). + let bothRest = level(engine: engine, ams: 3, range: 99, breath: 0, aftertouchRange: 13) + let breathRest = level(engine: engine, ams: 3, range: 99, breath: 0) + #expect(bothRest <= breathRest + 0.01, "both at rest stay held down at least as far as breath alone") + } + + @Test("EG bias and LFO AMD share the AMS path: the deeper one applies", arguments: [FMEngine.modern, .markI]) + func combinesWithLFOAmd(engine: FMEngine) { + // A held-down operator (breath at rest, range 99) is not opened by an LFO AMD setting. + let e = make(engine: engine, ams: 3, range: 99) + e.setLFOAMD(50) + controller(e, 2, 0) + e.sendMIDI(MIDIEvent(kind: .noteOn, data1: 60, data2: UInt32(100) << 9)) + _ = render(e, blocks: 16) + let x = render(e, blocks: 64) + let withAMD = 10 * log10(x.reduce(0) { $0 + $1 * $1 } / Float(x.count) + 1e-20) + let without = level(engine: engine, ams: 3, range: 99, breath: 0) + #expect(withAMD <= without + 0.5, "LFO AMD must not lift an operator that EG bias holds down") } }