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

## [Unreleased]

## [1.22.1] - 2026-09-29

### Fixed
- **Independent Mark I depth (M2DX #119).** Each engine now publishes its depth
with the other voice parameters. Creating or editing another AU instance cannot
alter its sound, and open editor batches no longer leak depth changes to render.
- **Host sample-rate publication (M2DX #119).** `setSampleRate` uses an independent
atomic value so host resource allocation can overlap UI parameter edits without
becoming a second snapshot producer. The next render uses the requested rate,
including during an open editor batch; later UI snapshots cannot revert it.
- **Legato level-edit round trips (M2DX #112).** OL and L3 edits now use the keyboard
scaling that produced the held sustain. Muting works after a legato pitch change,
and restoring OL or L3 no longer accumulates gain or loses the original level at
the scaling ceiling. Release retains that start level and targets the current
key's L4, including nonzero L4 and mute/restore during the tail. Editing L4 during
release continues from the audible level at R4; subsequent OL round trips retain
this new trajectory. Unedited legato output remains bit-identical to 1.22.0.

## [1.22.0] - 2026-09-15

### Added
Expand Down
21 changes: 18 additions & 3 deletions Sources/M2DXCore/Engine/DX7Envelope.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ package struct DX7Envelope {
// Keep attenuation separate from the running EG so crossing the silence floor
// cannot finish an attack/decay/release early. Always <= 0; louder edits rebase.
private var liveOutputOffset: Int = 0
// A legato sustain keeps the preceding key's gain, but release still targets
// the new key's L4. Do not use that target as the basis of held OL/L3 edits.
var releaseReferenceOutput: Int? = nil
var rateScaling: Int = 0

var srMultiplier: Int64 = 1 << 24 // Q24: (44100/sampleRate) × (1<<24)
Expand Down Expand Up @@ -62,6 +65,7 @@ package struct DX7Envelope {
mutating func setOutputLevel(_ ol: Int) {
outlevel = scaleOutputLevel(ol) << 5
liveOutputOffset = 0
releaseReferenceOutput = nil
}

/// Change the output offset of a running envelope without restarting its stage.
Expand Down Expand Up @@ -100,11 +104,20 @@ package struct DX7Envelope {

mutating func noteOn() {
liveOutputOffset = 0
releaseReferenceOutput = nil
level = 0
down = true
advance(0)
}

/// Replace the release reference without restarting its stage or rate.
mutating func setReleaseReference(level: Int32, output: Int, target: Int) {
self.level = level
outlevel = output
liveOutputOffset = 0
releaseReferenceOutput = target
}

mutating func noteOff(held: Bool = false) {
if held { return }
if ix >= 0 {
Expand Down Expand Up @@ -156,7 +169,7 @@ package struct DX7Envelope {
}

@inline(__always)
private func levelFor(_ egLevel: Int, output: Int) -> Int32 {
func levelFor(_ egLevel: Int, output: Int) -> Int32 {
Int32(max(16, ((scaleOutputLevel(egLevel) >> 1) << 6) + output - 4256) << 16)
}

Expand All @@ -181,7 +194,8 @@ package struct DX7Envelope {
default: newLevel = 0
}

targetLevel = levelFor(newLevel, output: outlevel - liveOutputOffset)
targetLevel = levelFor(newLevel, output: ix == 3
? releaseReferenceOutput ?? referenceOutput : referenceOutput)
rising = targetLevel > level

if targetLevel == level {
Expand Down Expand Up @@ -234,7 +248,8 @@ package struct DX7Envelope {
case 3: newLevel = levels.3
default: newLevel = 0
}
targetLevel = levelFor(newLevel, output: outlevel - liveOutputOffset)
targetLevel = levelFor(newLevel, output: ix == 3
? releaseReferenceOutput ?? referenceOutput : referenceOutput)
rising = targetLevel > level
}
}
30 changes: 17 additions & 13 deletions Sources/M2DXCore/Engine/DX7MarkI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ func dac12bitCompand(_ sample: Float) -> Float {
/// #95: full-scale reference for per-voice DAC companding — per-voice raw OPS samples are
/// divided by this before `dac12bitCompand` so its (−1,1)-normalized exponent selector
/// (0.5/0.25/0.125) engages across the real dynamic range, then multiplied back.
/// Runtime-settable for calibration A/B tests (like `markIModScaleQ12`); shipping default
/// Runtime-settable for DAC characterization tests; shipping default
/// chosen by ear-A/B (#95): 2^25. At 2^26 (the OPS single-op theoretical max = `8192 << 13`),
/// ~80% of samples stayed at exponent 8 (inaudible fine-grid region); 2^25 shifts engagement
/// into exp 2/4 for an audible-but-not-crushed vintage character.
Expand All @@ -70,16 +70,17 @@ let kMarkILevelThresh: UInt16 = UInt16(kMarkIEnvMax - 100)
/// the modulator output feeds the carrier phase scaled DOWN, which is what gives
/// the OPS its darker, warmer timbre. Default 512 = ÷8 — the bit-exact-parity
/// reference (`(v·512)>>12 == v>>3`, the value the dx7refmki C twin uses); the
/// app overrides it via `SynthEngine.setMarkIModDivisor`, which is calibrated
/// app overrides it per engine via `SynthEngine.setMarkIModDivisor`, calibrated
/// against real Dexed Mark I (BASS 1 / alg15, E.PIANO 1 / alg4). Q12 gives
/// meaningful 0.1-step ÷X resolution across ÷2…÷16. Only the FORWARD modulation
/// is scaled; the carrier DAC output (`<< 13`) and the feedback path are left
/// unscaled. Written by an aligned-Int32 store (atomic on arm64).
nonisolated(unsafe) var markIModScaleQ12: Int32 = 512
/// unscaled. This constant is the kernel default; SynthEngine passes each voice's
/// snapshot value explicitly instead of sharing a mutable process-global scale.
let kMarkIDefaultModScaleQ12: Int32 = 512

@inline(__always)
private func markIModScale(_ v: Int32) -> Int32 {
Int32(truncatingIfNeeded: (Int64(v) &* Int64(markIModScaleQ12)) >> 12)
private func markIModScale(_ v: Int32, scaleQ12: Int32) -> Int32 {
Int32(truncatingIfNeeded: (Int64(v) &* Int64(scaleQ12)) >> 12)
}

// MARK: - Single-input FM kernels (mod / pure / fb)
Expand All @@ -93,11 +94,12 @@ private func attenRamp(_ a1: UInt16, _ a2: UInt16, _ n: Int) -> Int32 {

@inline(__always)
func computeModMkI(_ output: UnsafeMutablePointer<Int32>, _ input: UnsafePointer<Int32>,
phase0: Int32, freq: Int32, atten1: UInt16, atten2: UInt16, add: Bool, n: Int) {
phase0: Int32, freq: Int32, atten1: UInt16, atten2: UInt16, add: Bool, n: Int,
modScaleQ12: Int32 = kMarkIDefaultModScaleQ12) {
let dA = attenRamp(atten1, atten2, n); var aAcc = Int32(atten1); var phase = phase0
for i in 0..<n {
aAcc &+= dA
let y = mkiSin(phase &+ markIModScale(input[i]), UInt16(truncatingIfNeeded: max(0, aAcc)))
let y = mkiSin(phase &+ markIModScale(input[i], scaleQ12: modScaleQ12), UInt16(truncatingIfNeeded: max(0, aAcc)))
output[i] = add ? (output[i] &+ y) : y
phase &+= freq
}
Expand Down Expand Up @@ -143,7 +145,8 @@ struct MarkIChainParams {

@inline(__always)
func computeFb2MkI(_ output: UnsafeMutablePointer<Int32>, _ p: inout MarkIChainParams,
atten01: UInt16, atten02: UInt16, fbBuf: inout (Int32, Int32), fbShift: Int) {
atten01: UInt16, atten02: UInt16, fbBuf: inout (Int32, Int32), fbShift: Int,
modScaleQ12: Int32 = kMarkIDefaultModScaleQ12) {
let dA0 = attenRamp(atten01, atten02, kBlockSize); var a0 = Int32(atten01)
let a1Target = markIAtten(p.levelIn.1)
var ph0 = p.phase.0; var ph1 = p.phase.1
Expand All @@ -154,7 +157,7 @@ func computeFb2MkI(_ output: UnsafeMutablePointer<Int32>, _ p: inout MarkIChainP
y0 = y
y = mkiSin(ph0 &+ scaledFb, UInt16(truncatingIfNeeded: max(0, a0)))
ph0 &+= p.freq.0
y = mkiSin(ph1 &+ markIModScale(y), a1Target)
y = mkiSin(ph1 &+ markIModScale(y, scaleQ12: modScaleQ12), a1Target)
ph1 &+= p.freq.1
output[i] = y
}
Expand All @@ -164,7 +167,8 @@ func computeFb2MkI(_ output: UnsafeMutablePointer<Int32>, _ p: inout MarkIChainP

@inline(__always)
func computeFb3MkI(_ output: UnsafeMutablePointer<Int32>, _ p: inout MarkIChainParams,
atten01: UInt16, atten02: UInt16, fbBuf: inout (Int32, Int32), fbShift: Int) {
atten01: UInt16, atten02: UInt16, fbBuf: inout (Int32, Int32), fbShift: Int,
modScaleQ12: Int32 = kMarkIDefaultModScaleQ12) {
let dA0 = attenRamp(atten01, atten02, kBlockSize); var a0 = Int32(atten01)
let a1 = markIAtten(p.levelIn.1); let a2 = markIAtten(p.levelIn.2)
var ph0 = p.phase.0; var ph1 = p.phase.1; var ph2 = p.phase.2
Expand All @@ -174,8 +178,8 @@ func computeFb3MkI(_ output: UnsafeMutablePointer<Int32>, _ p: inout MarkIChainP
a0 &+= dA0
y0 = y
y = mkiSin(ph0 &+ scaledFb, UInt16(truncatingIfNeeded: max(0, a0))); ph0 &+= p.freq.0
y = mkiSin(ph1 &+ markIModScale(y), a1); ph1 &+= p.freq.1
y = mkiSin(ph2 &+ markIModScale(y), a2); ph2 &+= p.freq.2
y = mkiSin(ph1 &+ markIModScale(y, scaleQ12: modScaleQ12), a1); ph1 &+= p.freq.1
y = mkiSin(ph2 &+ markIModScale(y, scaleQ12: modScaleQ12), a2); ph2 &+= p.freq.2
output[i] = y
}
p.phase.0 = ph0; p.phase.1 = ph1; p.phase.2 = ph2
Expand Down
119 changes: 112 additions & 7 deletions Sources/M2DXCore/Engine/DX7Operator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ package struct DX7Operator {
// Highest raw OL used by the current EG trajectory. Keep the raw value so
// legato KLS clipping cannot accumulate an unbounded attenuation offset.
private var envelopeReferenceOL: Int = 99
private var heldKeyboardOffset: Int? = nil
private var heldReferenceOL: Int = 99
private var releaseReferenceOL: Int? = nil
private var releaseStartLevel: Int32 = 0
private var releaseStartL3: Int = 99
private var releaseStartOutput: Int? = nil
var phase: Int32 = 0 // Q24 phase accumulator
var freq: Int32 = 0 // Q24 per-sample phase increment
var gainOut: Int32 = 0 // Previous block's gain (for interpolation)
Expand Down Expand Up @@ -52,6 +58,8 @@ package struct DX7Operator {
frequency = baseFreq * ratio * detune
updateFreq()
envelopeReferenceOL = outputLevel
heldKeyboardOffset = nil
releaseReferenceOL = nil
env.noteOn()
phase = 0; fbBuf = (0, 0); gainOut = 0
}
Expand All @@ -66,29 +74,94 @@ package struct DX7Operator {
applyPitchBend(factor)
}

mutating func noteOff() { env.noteOff() }
mutating func noteOff() {
if let heldKeyboardOffset, env.ix == 3, env.down {
envelopeReferenceOL = max(heldReferenceOL, outputLevel)
let current = combinedOutputLevel(outputLevel, keyboardOffset: heldKeyboardOffset)
let heldReference = env.levelFor(env.levels.2,
output: combinedOutputLevel(envelopeReferenceOL, keyboardOffset: heldKeyboardOffset))
let releaseReference = env.levelFor(env.levels.3,
output: combinedOutputLevel(envelopeReferenceOL, keyboardOffset: klsOffset))
// If the old reference adds no held headroom, discard its history.
// A flat reference also has no duration to preserve; use the edited
// trajectory so a non-flat release is not mistaken for immediate idle.
if env.levelFor(env.levels.2, output: current) == heldReference || heldReference == releaseReference {
envelopeReferenceOL = outputLevel
}
let reference = combinedOutputLevel(envelopeReferenceOL, keyboardOffset: heldKeyboardOffset)
releaseReferenceOL = envelopeReferenceOL
releaseStartL3 = env.levels.2
releaseStartOutput = nil
env.setReleaseReference(
level: env.levelFor(releaseStartL3, output: reference), output: reference,
target: combinedOutputLevel(envelopeReferenceOL, keyboardOffset: klsOffset))
releaseStartLevel = env.level
}
env.noteOff()
}

mutating func setOutputLevel(_ level: Int) {
outputLevel = min(99, max(0, level))
// The reference release keeps running while OL changes its audible endpoints.
if releaseReferenceOL != nil, env.isActive { return }
envelopeReferenceOL = env.isActive ? max(envelopeReferenceOL, outputLevel) : outputLevel
env.updateOutputLevel(combinedOutputLevel(outputLevel))
env.updateOutputLevel(combinedOutputLevel(outputLevel, keyboardOffset: heldKeyboardOffset ?? klsOffset))
refreshReleaseTarget()
}

mutating func setEnvelopeLevels(_ a: Int, _ b: Int, _ c: Int, _ d: Int) {
if releaseReferenceOL != nil, env.isActive,
scaleOutputLevel(env.levels.3) >> 1 != scaleOutputLevel(min(99, max(0, d))) >> 1,
let heldKeyboardOffset {
// L4 changes the trajectory itself. Anchor its new start at the current
// audible level before replacing the old endpoint, so R4 continues
// smoothly even when the old reference and edited OL clip differently.
let audibleLevel = releaseLevel(env.level)
let output = combinedOutputLevel(outputLevel, keyboardOffset: heldKeyboardOffset)
releaseStartOutput = scaleOutputLevel(outputLevel) << 5
releaseStartLevel = audibleLevel
releaseReferenceOL = outputLevel
env.setReleaseReference(level: audibleLevel, output: output,
target: combinedOutputLevel(outputLevel, keyboardOffset: klsOffset))
}
env.setLevels(a, b, c, d)
}

/// Called before replacing KLS on a legato retarget. Once L3 is held, its
/// audible level belongs to this key until note-off, even as pitch changes.
mutating func preserveHeldKeyboardLevel() {
if heldKeyboardOffset == nil, env.ix == 3, env.down {
heldKeyboardOffset = klsOffset
heldReferenceOL = envelopeReferenceOL
}
}

/// Follow the legato note's KLS while retaining the running envelope trajectory.
mutating func refreshKeyboardOutputLevel() {
env.updateKeyboardOutputLevel(combinedOutputLevel(outputLevel),
reference: combinedOutputLevel(envelopeReferenceOL))
if heldKeyboardOffset != nil {
refreshReleaseTarget()
} else {
env.updateKeyboardOutputLevel(combinedOutputLevel(outputLevel, keyboardOffset: klsOffset),
reference: combinedOutputLevel(envelopeReferenceOL, keyboardOffset: klsOffset))
}
}

private mutating func refreshReleaseTarget() {
guard heldKeyboardOffset != nil else { return }
env.releaseReferenceOutput = combinedOutputLevel(releaseReferenceOL ?? envelopeReferenceOL, keyboardOffset: klsOffset)
env.recalcTargetLevel()
}

private func combinedOutputLevel(_ ol: Int) -> Int {
max(0, (min(127, scaleOutputLevel(ol) + klsOffset) << 5) + velocityOffset)
private func combinedOutputLevel(_ ol: Int, keyboardOffset: Int) -> Int {
max(0, (min(127, scaleOutputLevel(ol) + keyboardOffset) << 5) + velocityOffset)
}

/// Update gain from EG. Called once per block before compute.
@inline(__always)
mutating func updateGain(lfoAmpMod: Int32, egBiasOL: Int32 = 0) {
let wasActive = env.isActive
let egLevel = env.getsample()
levelIn = egLevel
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`
Expand All @@ -110,6 +183,38 @@ package struct DX7Operator {
}
}

/// Project the retained release onto the edited start/end levels. The start
/// belongs to the held key and L3 at note-off; the end belongs to the current
/// key and L4. A constant offset cannot represent different KLS clipping at
/// those two ends. Restoring the reference OL makes this mapping bit-exact.
@inline(__always)
private func releaseLevel(_ referenceLevel: Int32) -> Int32 {
guard let heldKeyboardOffset else { return referenceLevel }
let nominalStart: Int32
if let releaseStartOutput {
// A retargeted release can have risen from a floor-clamped L3. Use
// the raw OL difference here: either key's clipped KLS output can
// otherwise hide a real attenuation change. Clamp only the result.
nominalStart = releaseStartLevel
+ Int32(((scaleOutputLevel(outputLevel) << 5) - releaseStartOutput) << 16)
} else {
nominalStart = env.levelFor(releaseStartL3,
output: combinedOutputLevel(outputLevel, keyboardOffset: heldKeyboardOffset))
}
let ceiling = env.levelFor(99, output: combinedOutputLevel(99, keyboardOffset: 127))
let start = min(ceiling, max(16 << 16, nominalStart))
let end = env.levelFor(env.levels.3,
output: combinedOutputLevel(outputLevel, keyboardOffset: klsOffset))
let referenceEnd = env.targetLevel
if start == releaseStartLevel, end == referenceEnd { return referenceLevel }
let span = Int64(releaseStartLevel) - Int64(referenceEnd)
guard span != 0 else { return end }
// Limit rounding at the endpoints rather than extrapolating extra gain.
let distance = Int64(referenceLevel) - Int64(referenceEnd)
let progress = span > 0 ? min(span, max(0, distance)) : max(span, min(0, distance))
return end + Int32((Int64(start) - Int64(end)) * progress / span)
}

mutating func updateFreqPublic() { updateFreq() }

private mutating func updateFreq() {
Expand Down
Loading
Loading