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.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 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
output is unchanged.

## [1.23.0] - 2026-09-30

### Added
Expand Down
17 changes: 2 additions & 15 deletions Sources/M2DXCore/Engine/DX7Operator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 3 additions & 5 deletions Sources/M2DXCore/Engine/DX7Voice.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
27 changes: 19 additions & 8 deletions Sources/M2DXCore/Engine/SynthEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -1540,11 +1542,19 @@ 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 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..<maxV {
Expand Down Expand Up @@ -1640,7 +1650,9 @@ public final class SynthEngine: @unchecked Sendable {
let lfoAtten = 1.0 - lfoUni
let amdDepth = lfoAtten * slotMods[s].lfoAMDNorm
let controllerAmd: Float = voicesDX7[i].detached ? 0.0 : (1.0 - slotMods[s].controllerAmpMod)
let totalAmd = (amdDepth + controllerAmd) * 12.0
// EG bias and LFO AMD share the AMS-weighted path; the deeper of the two applies (#163).
let egBiasAtten: Float = voicesDX7[i].detached ? 0.0 : slotMods[s].egBiasAtten
let totalAmd = (max(amdDepth, egBiasAtten) + controllerAmd) * 12.0
var lfoAmpModVal = Int32(totalAmd * Float(1 << 24))

// Per-note volume attenuation in log domain
Expand All @@ -1651,7 +1663,6 @@ 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
180 changes: 126 additions & 54 deletions Tests/M2DXCoreTests/EGBiasTests.swift
Original file line number Diff line number Diff line change
@@ -1,63 +1,135 @@
// 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.
// M2DX-Core — controller→EG bias with DX7 semantics (M2DX #163, replaces the #97 OL boost).
//
// On the DX7, EG bias acts only on operators with AMS (amplitude modulation sensitivity): a
// controller's EG-bias range holds those operators down by range/99 at the controller's minimum
// and releases them to their programmed level at its maximum. Range 0 means EG bias off. The
// #97 implementation raised the output level of all six operators instead, so a fast breath
// rise brightened modulators too and gave wind sounds a sharp attack (M2DX #163).

import Foundation
import Testing
@testable import M2DXCore

@Suite("EG Bias (#97)")
@Suite("EG Bias follows the DX7 (M2DX #163)", .serialized)
struct EGBiasTests {
/// One sounding carrier (OP1, algorithm 32) with the given AMS. Breath EG bias range `range`.
private func make(engine fm: FMEngine, ams: UInt8, range: UInt8, aftertouchRange: UInt8 = 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")
}

@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")
}

/// 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("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")
}
}
8 changes: 4 additions & 4 deletions docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading