From cc2650f5b146b13f0910576c54672d0d8624cc8f Mon Sep 17 00:00:00 2001 From: Hakaru Hirose Date: Tue, 15 Sep 2026 23:38:56 +0900 Subject: [PATCH 1/5] fix(envelope): preserve held-key scaling through live level edits (M2DX #112) --- CHANGELOG.md | 9 + Sources/M2DXCore/Engine/DX7Envelope.swift | 21 +- Sources/M2DXCore/Engine/DX7Operator.swift | 119 ++++++++- Sources/M2DXCore/Engine/DX7Voice.swift | 3 +- .../LiveOutputLevelRoundTripTests.swift | 240 ++++++++++++++++++ 5 files changed, 381 insertions(+), 11 deletions(-) create mode 100644 Tests/M2DXCoreTests/LiveOutputLevelRoundTripTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index c5f5f4f..649f5c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed +- **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 diff --git a/Sources/M2DXCore/Engine/DX7Envelope.swift b/Sources/M2DXCore/Engine/DX7Envelope.swift index d0a7379..adc5460 100644 --- a/Sources/M2DXCore/Engine/DX7Envelope.swift +++ b/Sources/M2DXCore/Engine/DX7Envelope.swift @@ -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) @@ -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. @@ -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 { @@ -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) } @@ -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 { @@ -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 } } diff --git a/Sources/M2DXCore/Engine/DX7Operator.swift b/Sources/M2DXCore/Engine/DX7Operator.swift index 1ca5249..9c1aeea 100644 --- a/Sources/M2DXCore/Engine/DX7Operator.swift +++ b/Sources/M2DXCore/Engine/DX7Operator.swift @@ -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) @@ -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 } @@ -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` @@ -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() { diff --git a/Sources/M2DXCore/Engine/DX7Voice.swift b/Sources/M2DXCore/Engine/DX7Voice.swift index f331958..7a2e544 100644 --- a/Sources/M2DXCore/Engine/DX7Voice.swift +++ b/Sources/M2DXCore/Engine/DX7Voice.swift @@ -203,6 +203,7 @@ package struct DX7Voice { op.baseFrequency = kMIDIFreqLUT[Int(n)] op.detune = dexedDetuneFactor(op.baseFrequency, detuneCents: op.detuneCents) } + op.preserveHeldKeyboardLevel() op.klsOffset = scaleKeyboardLevel(n, breakPoint: snapshot.klsBreakPoint, leftDepth: snapshot.klsLeftDepth, rightDepth: snapshot.klsRightDepth, leftCurve: snapshot.klsLeftCurve, rightCurve: snapshot.klsRightCurve) @@ -759,7 +760,7 @@ package struct DX7Voice { op.detune = op.isFixedFreq ? params.detune : dexedDetuneFactor(op.baseFrequency, 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) + op.setEnvelopeLevels(params.dx7EgL0, params.dx7EgL1, params.dx7EgL2, params.dx7EgL3) // Recompute frequency from the new ratio/detune so live edits affect a // held note (ratio/detune alone don't refresh `freq`; only note-on and // applyPitchBend do). Skip fixed-frequency operators. diff --git a/Tests/M2DXCoreTests/LiveOutputLevelRoundTripTests.swift b/Tests/M2DXCoreTests/LiveOutputLevelRoundTripTests.swift new file mode 100644 index 0000000..57794e6 --- /dev/null +++ b/Tests/M2DXCoreTests/LiveOutputLevelRoundTripTests.swift @@ -0,0 +1,240 @@ +import Foundation +import Testing +@testable import M2DXCore + +@Suite("Live operator level round trips (M2DX #112)", .serialized) +struct LiveOutputLevelRoundTripTests { + private func render(_ engine: SynthEngine, blocks: Int = 1) -> [Float] { + let count = blocks * 64 + var l = [Float](repeating: 0, count: count), r = l + l.withUnsafeMutableBufferPointer { lp in + r.withUnsafeMutableBufferPointer { rp in + engine.render(into: lp.baseAddress!, bufferR: rp.baseAddress!, frameCount: count) + } + } + return l + } + + private func rms(_ values: [Float]) -> Double { + sqrt(values.reduce(0) { $0 + Double($1) * Double($1) } / Double(values.count)) + } + + private func setup(_ mode: FMEngine, ol: Int, l3: Int, depth: Int, curve: Int, + first: UInt8, second: UInt8, l4: Int = 0) -> SynthEngine { + let engine = SynthEngine() + engine.setSampleRate(48000) + engine.setFMEngine(mode) + engine.setMonoPerformance(enabled: true, portamentoMode: .fingered, glissando: false) + let ops = (0..<6).map { i in + i == 5 ? DX7OperatorPreset(outputLevel: ol, egRate1: 99, egRate2: 99, egRate3: 99, + egRate4: 40, egLevel1: 99, egLevel2: 99, egLevel3: l3, egLevel4: l4, + klsBreakPoint: 39, klsRightDepth: depth, klsRightCurve: curve) + : DX7OperatorPreset(outputLevel: 0) + } + engine.loadDX7Preset(DX7Preset(name: "Review #112", algorithm: 31, feedback: 0, + operators: ops, category: .other)) + _ = render(engine) + engine.sendMIDI(MIDIEvent(kind: .noteOn, data1: first, data2: UInt32(100) << 9)) + _ = render(engine, blocks: 200) + engine.sendMIDI(MIDIEvent(kind: .noteOn, data1: second, data2: UInt32(100) << 9)) + _ = render(engine, blocks: 20) + return engine + } + + @Test("OL0 must silence a held legato carrier", arguments: [FMEngine.modern, .markI]) + func muteAfterLegato(_ mode: FMEngine) { + let edited = setup(mode, ol: 99, l3: 99, depth: 99, curve: 0, first: 36, second: 127) + let control = setup(mode, ol: 99, l3: 99, depth: 99, curve: 0, first: 36, second: 127) + edited.setOperatorDX7OutputLevel(0, level: 0) + _ = render(edited, blocks: 2); _ = render(control, blocks: 2) + let changed = render(edited, blocks: 64), original = render(control, blocks: 64) + let ratio = rms(changed) / rms(original) + #expect(ratio < 0.001) + } + + @Test("L3 round trip must restore held legato level", arguments: [FMEngine.modern, .markI]) + func l3RoundTrip(_ mode: FMEngine) { + let edited = setup(mode, ol: 99, l3: 90, depth: 60, curve: 0, first: 96, second: 36) + let control = setup(mode, ol: 99, l3: 90, depth: 60, curve: 0, first: 96, second: 36) + edited.setOperatorDX7EGLevels(0, l1: 99, l2: 99, l3: 0, l4: 0) + _ = render(edited, blocks: 2); _ = render(control, blocks: 2) + edited.setOperatorDX7EGLevels(0, l1: 99, l2: 99, l3: 90, l4: 0) + _ = render(edited, blocks: 2); _ = render(control, blocks: 2) + let changed = rms(render(edited, blocks: 64)), original = rms(render(control, blocks: 64)) + let ratio = changed / original + #expect(abs(ratio - 1) < 0.005) + } + + @Test("OL raise and restore must preserve held legato level", arguments: [FMEngine.modern, .markI]) + func olRoundTrip(_ mode: FMEngine) { + let edited = setup(mode, ol: 30, l3: 99, depth: 99, curve: 3, first: 127, second: 36) + let control = setup(mode, ol: 30, l3: 99, depth: 99, curve: 3, first: 127, second: 36) + edited.setOperatorDX7OutputLevel(0, level: 99) + _ = render(edited, blocks: 2); _ = render(control, blocks: 2) + edited.setOperatorDX7OutputLevel(0, level: 30) + _ = render(edited, blocks: 2); _ = render(control, blocks: 2) + let ratio = rms(render(edited, blocks: 64)) / rms(render(control, blocks: 64)) + #expect(abs(ratio - 1) < 0.005) + } + + @Test("repeated L3 and OL round trips cannot accumulate gain", arguments: [FMEngine.modern, .markI]) + func repeatedEdits(_ mode: FMEngine) { + let edited = setup(mode, ol: 99, l3: 90, depth: 60, curve: 0, first: 96, second: 36) + let control = setup(mode, ol: 99, l3: 90, depth: 60, curve: 0, first: 96, second: 36) + for _ in 0..<32 { + edited.setOperatorDX7OutputLevel(0, level: 0) + edited.setOperatorDX7EGLevels(0, l1: 99, l2: 99, l3: 0, l4: 0) + _ = render(edited, blocks: 2); _ = render(control, blocks: 2) + edited.setOperatorDX7OutputLevel(0, level: 99) + edited.setOperatorDX7EGLevels(0, l1: 99, l2: 99, l3: 90, l4: 0) + _ = render(edited, blocks: 2); _ = render(control, blocks: 2) + #expect(render(edited, blocks: 4) == render(control, blocks: 4)) + } + } + + @Test("a restored OL has the original release, including nonzero L4", + arguments: [FMEngine.modern, .markI], [0, 60, 99]) + func restoredRelease(_ mode: FMEngine, l4: Int) { + let edited = setup(mode, ol: 30, l3: 99, depth: 99, curve: 3, first: 127, second: 36, l4: l4) + let control = setup(mode, ol: 30, l3: 99, depth: 99, curve: 3, first: 127, second: 36, l4: l4) + edited.setOperatorDX7OutputLevel(0, level: 99) + _ = render(edited, blocks: 2); _ = render(control, blocks: 2) + edited.setOperatorDX7OutputLevel(0, level: 30) + _ = render(edited, blocks: 2); _ = render(control, blocks: 2) + for s in [edited, control] { + s.sendMIDI(MIDIEvent(kind: .noteOff, data1: 127, data2: 0)) + s.sendMIDI(MIDIEvent(kind: .noteOff, data1: 36, data2: 0)) + } + for _ in 0..<64 { + #expect(render(edited, blocks: 8) == render(control, blocks: 8)) + #expect(edited.debugActiveVoiceCount == control.debugActiveVoiceCount) + } + } + + @Test("a legato note released under attenuation resumes its original tail", + arguments: [FMEngine.modern, .markI], [0, 60]) + func mutedLegatoRelease(_ mode: FMEngine, ol: Int) { + let edited = setup(mode, ol: 99, l3: 99, depth: 99, curve: 0, first: 36, second: 127) + let control = setup(mode, ol: 99, l3: 99, depth: 99, curve: 0, first: 36, second: 127) + edited.setOperatorDX7OutputLevel(0, level: ol) + _ = render(edited, blocks: 2); _ = render(control, blocks: 2) + for s in [edited, control] { + s.sendMIDI(MIDIEvent(kind: .noteOff, data1: 36, data2: 0)) + s.sendMIDI(MIDIEvent(kind: .noteOff, data1: 127, data2: 0)) + } + _ = render(edited, blocks: 40); _ = render(control, blocks: 40) + edited.setOperatorDX7OutputLevel(0, level: 99) + _ = render(edited, blocks: 2); _ = render(control, blocks: 2) + for _ in 0..<64 { + #expect(render(edited, blocks: 8) == render(control, blocks: 8)) + #expect(edited.debugActiveVoiceCount == control.debugActiveVoiceCount) + } + } + + @Test("a saturated held OL edit still reaches the current key's release level", + arguments: [FMEngine.modern, .markI]) + func saturatedReleaseTarget(_ mode: FMEngine) { + let edited = setup(mode, ol: 99, l3: 99, depth: 99, curve: 3, first: 127, second: 36, l4: 60) + let control = setup(mode, ol: 91, l3: 99, depth: 99, curve: 3, first: 127, second: 36, l4: 60) + // Both OL values are at the old key's ceiling, so the held waveforms agree. + #expect(render(edited, blocks: 8) == render(control, blocks: 8)) + edited.setOperatorDX7OutputLevel(0, level: 91) + _ = render(edited, blocks: 2); _ = render(control, blocks: 2) + for s in [edited, control] { + s.sendMIDI(MIDIEvent(kind: .noteOff, data1: 127, data2: 0)) + s.sendMIDI(MIDIEvent(kind: .noteOff, data1: 36, data2: 0)) + } + let identical = render(edited, blocks: 4096) == render(control, blocks: 4096) + #expect(identical) + } + + @Test("a partially attenuated legato release reaches the edited L4", arguments: [FMEngine.modern, .markI]) + func attenuatedReleaseTarget(_ mode: FMEngine) { + let s = setup(mode, ol: 99, l3: 99, depth: 99, curve: 3, first: 72, second: 36, l4: 60) + s.setOperatorDX7OutputLevel(0, level: 30) + _ = render(s, blocks: 2) + s.sendMIDI(MIDIEvent(kind: .noteOff, data1: 72, data2: 0)) + s.sendMIDI(MIDIEvent(kind: .noteOff, data1: 36, data2: 0)) + var finalLevel: Int32 = 0 + for _ in 0..<20000 { + _ = render(s) + let op = s.monoVoiceForTesting.ops.0 + finalLevel = op.levelIn + if !op.env.isActive { break } + } + // EG L4=60 and OL30, no KLS at note36: ((88 >> 1) << 6) + (58 << 5) - 4256. + #expect(finalLevel == 416 << 16) + } + + @Test("a flat reference release must not cut a non-flat edited release", arguments: [FMEngine.modern, .markI]) + func flatReferenceRelease(_ mode: FMEngine) { + let s = setup(mode, ol: 99, l3: 60, depth: 99, curve: 3, first: 72, second: 36, l4: 60) + s.setOperatorDX7OutputLevel(0, level: 30) + _ = render(s, blocks: 2) + s.sendMIDI(MIDIEvent(kind: .noteOff, data1: 72, data2: 0)) + s.sendMIDI(MIDIEvent(kind: .noteOff, data1: 36, data2: 0)) + _ = render(s) + #expect(s.monoVoiceForTesting.ops.0.env.isActive) + #expect(s.monoVoiceForTesting.ops.0.levelIn > 416 << 16) + } + + @Test("L4 edits during a rising release cannot leave audible gain outside OL control", + arguments: [FMEngine.modern, .markI], [false, true]) + func risingReleaseLevelAutomation(_ mode: FMEngine, heldFloor: Bool) { + let first: UInt8 = heldFloor ? 127 : 36 + let second: UInt8 = heldFloor ? 36 : 72 + let l3 = heldFloor ? 99 : 0 + let edited = setup(mode, ol: 99, l3: l3, depth: 99, curve: heldFloor ? 0 : 3, + first: first, second: second, l4: 99) + let control = setup(mode, ol: 99, l3: l3, depth: 99, curve: heldFloor ? 0 : 3, + first: first, second: second, l4: 99) + for s in [edited, control] { + s.sendMIDI(MIDIEvent(kind: .noteOff, data1: first, data2: 0)) + s.sendMIDI(MIDIEvent(kind: .noteOff, data1: second, data2: 0)) + _ = render(s, blocks: 100) + s.setOperatorDX7EGLevels(0, l1: 99, l2: 99, l3: l3, l4: 97) + _ = render(s) + } + let before = edited.monoVoiceForTesting.ops.0.levelIn + #expect(before > 1700 << 16) + edited.setOperatorDX7OutputLevel(0, level: 0) + _ = render(edited, blocks: 2); _ = render(control, blocks: 2) + #expect(edited.monoVoiceForTesting.ops.0.levelIn < before - (1000 << 16)) + edited.setOperatorDX7OutputLevel(0, level: 99) + _ = render(edited, blocks: 2); _ = render(control, blocks: 2) + #expect(render(edited, blocks: 64) == render(control, blocks: 64)) + } + + @Test("release L4 edits continue from the audible level and subsequent OL round trips restore it", + arguments: [FMEngine.modern, .markI], [60, 98, 99]) + func releaseLevelAutomation(_ mode: FMEngine, l4: Int) { + let edited = setup(mode, ol: 99, l3: 99, depth: 99, curve: 3, first: 72, second: 36) + let control = setup(mode, ol: 99, l3: 99, depth: 99, curve: 3, first: 72, second: 36) + for s in [edited, control] { + s.setOperatorDX7OutputLevel(0, level: 30) + _ = render(s, blocks: 2) + s.sendMIDI(MIDIEvent(kind: .noteOff, data1: 72, data2: 0)) + s.sendMIDI(MIDIEvent(kind: .noteOff, data1: 36, data2: 0)) + _ = render(s, blocks: 200) + let before = s.monoVoiceForTesting.ops.0.levelIn + s.setOperatorDX7EGLevels(0, l1: 99, l2: 99, l3: 0, l4: l4) + _ = render(s) + let op = s.monoVoiceForTesting.ops.0 + // The new L4 is still below the audible level at OL30. Continue at + // R4 from that level; changing L3 during release cannot move its start. + #expect(op.levelIn < before) + #expect(before - op.levelIn <= op.env.inc) + #expect(op.env.isActive) + _ = render(s, blocks: 10) + #expect(s.monoVoiceForTesting.ops.0.levelIn < op.levelIn) + } + // An L4 edit establishes a new trajectory at the audible level. OL-only + // excursions must then preserve that trajectory, including clipping. + edited.setOperatorDX7OutputLevel(0, level: 99) + _ = render(edited, blocks: 2); _ = render(control, blocks: 2) + edited.setOperatorDX7OutputLevel(0, level: 30) + _ = render(edited, blocks: 2); _ = render(control, blocks: 2) + let identical = render(edited, blocks: 64) == render(control, blocks: 64) + #expect(identical) + } +} From 6a8ce80b5d6529b439088ea6e30ccb67e74bbb57 Mon Sep 17 00:00:00 2001 From: Hakaru Hirose Date: Tue, 29 Sep 2026 15:34:41 +0900 Subject: [PATCH 2/5] fix: publish host sample rates without touching UI batches (M2DX #119) --- CHANGELOG.md | 4 + Sources/M2DXCore/Engine/SynthEngine.swift | 14 ++- .../SampleRatePublicationTests.swift | 117 ++++++++++++++++++ docs/API.md | 4 +- 4 files changed, 135 insertions(+), 4 deletions(-) create mode 100644 Tests/M2DXCoreTests/SampleRatePublicationTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 649f5c1..695ba0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Fixed +- **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 diff --git a/Sources/M2DXCore/Engine/SynthEngine.swift b/Sources/M2DXCore/Engine/SynthEngine.swift index 69111fe..d299517 100644 --- a/Sources/M2DXCore/Engine/SynthEngine.swift +++ b/Sources/M2DXCore/Engine/SynthEngine.swift @@ -105,6 +105,9 @@ public final class SynthEngine: @unchecked Sendable { private var shadowSnapshot = SynthParamSnapshot() private let snapshotRing = SnapshotRing(initial: SynthParamSnapshot()) private var currentSnapshot = SynthParamSnapshot() + // Host resource allocation may overlap an editor batch. Sample rate is an + // independent publication, never a second producer of the UI snapshot ring. + private let requestedSampleRateBits = Atomic(Float(44100).bitPattern) /// #89-detune: advances once per note-on so random voice-stack detune re-rolls /// every note. Audio-thread only (mutated solely inside doNoteOn) → no atomics. private var voiceStackNoteOnCounter: UInt64 = 0 @@ -361,12 +364,14 @@ public final class SynthEngine: @unchecked Sendable { } } + /// May be called from a host lifecycle thread. Takes effect at the next render + /// boundary, independently of an open UI parameter batch. public func setSampleRate(_ sr: Float) { // Validate: a zero / negative / non-finite rate would propagate to the // render thread and trap in Int(inc) / Int64(...) conversions. Clamp to a // sane audio range, falling back to 44.1 kHz for non-finite input. - shadowSnapshot.sampleRate = (sr.isFinite && sr >= 1) ? min(192000, max(8000, sr)) : 44100 - bumpVersion() + let rate: Float = (sr.isFinite && sr >= 1) ? min(192000, max(8000, sr)) : 44100 + requestedSampleRateBits.store(rate.bitPattern, ordering: .relaxed) } public func setAlgorithm(_ alg: Int) { @@ -1199,6 +1204,11 @@ public final class SynthEngine: @unchecked Sendable { if let newSnapshot = snapshotRing.popLatest() { currentSnapshot = newSnapshot } + let requestedSampleRate = Float(bitPattern: requestedSampleRateBits.load(ordering: .relaxed)) + if currentSnapshot.sampleRate != requestedSampleRate { + currentSnapshot.sampleRate = requestedSampleRate + automationDirty = true + } if monoModeGenerationRT != currentSnapshot.monoModeGeneration { monoModeGenerationRT = currentSnapshot.monoModeGeneration diff --git a/Tests/M2DXCoreTests/SampleRatePublicationTests.swift b/Tests/M2DXCoreTests/SampleRatePublicationTests.swift new file mode 100644 index 0000000..e6d0446 --- /dev/null +++ b/Tests/M2DXCoreTests/SampleRatePublicationTests.swift @@ -0,0 +1,117 @@ +import Testing +@testable import M2DXCore + +@Suite("Host sample-rate publication (M2DX #119)") +struct SampleRatePublicationTests { + private func render(_ engine: SynthEngine) -> SynthParamSnapshot { + var left = [Float](repeating: 0, count: 64), right = left + left.withUnsafeMutableBufferPointer { l in + right.withUnsafeMutableBufferPointer { r in + engine.render(into: l.baseAddress!, bufferR: r.baseAddress!, frameCount: 64) + } + } + return engine.debugCurrentSnapshot + } + + // A sample-rate request must not mutate the editor's shadow/batch or wait + // for endBatch: a host may render as soon as allocation has returned. + @Test("host allocation takes effect during an editor batch and survives its later snapshot", + arguments: [Float(44100), 48000, 96000]) + func hostRateDuringEditorBatch(rate: Float) async { + let engine = SynthEngine() + engine.setSampleRate(32000) + #expect(render(engine).sampleRate == 32000) + engine.beginBatch() + engine.setAlgorithm(19) + await Task.detached { engine.setSampleRate(rate) }.value + let duringBatch = render(engine) + #expect(duringBatch.sampleRate == rate) + #expect(duringBatch.algorithm == 0, "The host request must not publish half an editor batch") + engine.endBatch() + let afterBatch = render(engine) + #expect(afterBatch.sampleRate == rate) + #expect(afterBatch.algorithm == 19) + engine.setMasterVolume(0.25) + #expect(render(engine).sampleRate == rate, "Later UI snapshots must not revert the host rate") + } + + @Test("sample-rate validation remains finite and bounded", + arguments: [(Float.nan, Float(44100)), (.infinity, 44100), (0, 44100), + (-1, 44100), (1, 8000), (384000, 192000)]) + func validation(input: Float, expected: Float) { + let engine = SynthEngine() + engine.setSampleRate(input) + #expect(render(engine).sampleRate == expected) + } + + @Test("rate-only changes update the sounding voice at every oversampling mode", + arguments: [FMEngine.modern, .markI], [OversamplingMode.off, .highQuality, .lowCPU]) + func soundingPitch(fmEngine: FMEngine, oversampling: OversamplingMode) throws { + let engine = SynthEngine() + engine.setFMEngine(fmEngine) + engine.setOversamplingMode(oversampling) + engine.setSampleRate(48000) + engine.setAlgorithm(31) + for op in 0..<6 { + engine.setOperatorDX7OutputLevel(op, level: op == 0 ? 80 : 0) + engine.setOperatorRatio(op, ratio: 1) + engine.setOperatorDX7EGRates(op, r1: 99, r2: 99, r3: 99, r4: 99) + engine.setOperatorDX7EGLevels(op, l1: 99, l2: 99, l3: 99, l4: 0) + } + _ = render(engine) // Engine/oversampling switches retire old voices before note-on. + engine.sendMIDI(MIDIEvent(kind: .noteOn, data1: 69, data2: UInt32(100) << 9)) + _ = render(engine) + for rate: Float in [44100, 96000, 48000] { + engine.setSampleRate(rate) // No parameter publication to trigger applyParams. + _ = render(engine) // Settle rate/downsampler transition before measuring. + var left = [Float](repeating: 0, count: 8192), right = left + left.withUnsafeMutableBufferPointer { l in + right.withUnsafeMutableBufferPointer { r in + for offset in stride(from: 0, to: l.count, by: 64) { + engine.render(into: l.baseAddress! + offset, bufferR: r.baseAddress! + offset, frameCount: 64) + } + } + } + var crossings: [Float] = [] + for i in 1024..= 0 { + crossings.append(Float(i - 1) - left[i - 1] / (left[i] - left[i - 1])) + } + #expect(crossings.count > 20, "A sustained carrier must remain audible") + let first = try #require(crossings.first), last = try #require(crossings.last) + let hz = Float(crossings.count - 1) * rate / (last - first) + #expect(abs(hz - 440) < 1, "Rate \(rate), mode \(oversampling): sounding pitch \(hz) Hz") + } + } + + @Test("host, editor, and render publications can overlap") + func concurrentPublications() async { + let engine = SynthEngine() + await withTaskGroup(of: Void.self) { group in + group.addTask { + for i in 0..<256 { + engine.setSampleRate(i % 2 == 0 ? 48000 : 96000) + await Task.yield() + } + } + group.addTask { + for i in 0..<256 { + engine.beginBatch() + engine.setAlgorithm(i % 32) + await Task.yield() + engine.setMasterVolume(0.25) + engine.endBatch() + } + } + group.addTask { + for _ in 0..<256 { + _ = render(engine) + await Task.yield() + } + } + } + let final = render(engine) + #expect(final.sampleRate == 96000) + #expect(final.algorithm == 31) + #expect(final.masterVolume == 0.25) + } +} diff --git a/docs/API.md b/docs/API.md index ca12864..fa942bb 100644 --- a/docs/API.md +++ b/docs/API.md @@ -28,7 +28,7 @@ public final class SynthEngine: @unchecked Sendable DX7 FM synthesis engine with lock-free UI → audio thread parameter transfer. -**Thread safety:** UI thread writes parameters via setter methods; audio thread reads via `render()`. No locks on the render path — all cross-thread communication uses atomic SPSC ring buffers. +**Thread safety:** UI thread writes parameters via setter methods; audio thread reads via `render()`. Parameter snapshots use an atomic SPSC ring buffer with a single UI producer. `setSampleRate(_:)` is an exception: it may be called from a host lifecycle thread and publishes an independent atomic value, applied at the next render boundary even while a UI batch is open. It does not flush that batch. The render path takes no locks. ### Initialization @@ -64,7 +64,7 @@ Enqueue a MIDI event from the UI thread. Lock-free, allocation-free. | Method | Range | Description | |--------|-------|-------------| -| `setSampleRate(_ sr: Float)` | > 0 | Audio sample rate in Hz | +| `setSampleRate(_ sr: Float)` | 8000–192000 | Audio sample rate in Hz; values ≥ 1 are clamped, invalid/non-finite values fall back to 44100. Callable from a host lifecycle thread. | | `setMasterVolume(_ vol: Float)` | 0.0–1.0 | Master output volume | | `setMasterTuning(_ cents: Int16)` | -100–100 | Master fine tuning in cents | | `setAlgorithm(_ alg: Int)` | 0–31 | FM algorithm (0-indexed) | From 9d23fd22730fa6d036c0a81c371fccd25861ff01 Mon Sep 17 00:00:00 2001 From: Hakaru Hirose Date: Tue, 29 Sep 2026 15:34:41 +0900 Subject: [PATCH 3/5] test: retain cross-revision legato audio comparison (M2DX #112) --- .../UneditedLegatoABTests.swift | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 Tests/ComparisonFixtures/UneditedLegatoABTests.swift diff --git a/Tests/ComparisonFixtures/UneditedLegatoABTests.swift b/Tests/ComparisonFixtures/UneditedLegatoABTests.swift new file mode 100644 index 0000000..7e2dce9 --- /dev/null +++ b/Tests/ComparisonFixtures/UneditedLegatoABTests.swift @@ -0,0 +1,62 @@ +// Opt-in cross-revision comparison fixture (M2DX #112). +// Copy this file into Tests/M2DXCoreTests in two independent Core checkouts, +// run `swift test -c release --filter UneditedLegatoABTests` in each, then +// compare the 48 UNEDITED CSV lines. Remove the copied files afterward. +// The hashes intentionally are not portable golden assertions (libm/toolchain +// differences); compare both revisions on the same machine and toolchain. +// 2026-09-29: v1.22.0 vs cc2650f + atomic host sample-rate publication: +// all 48 complete stereo sample streams were bit-identical. + +import CryptoKit +import Foundation +import Testing +@testable import M2DXCore + +@Suite("Unedited legato 1.22.0 comparison", .serialized) +struct UneditedLegatoABTests { + @Test("render without editing across attack, sustain, legato retarget and release") + func compare() { + for mode in [FMEngine.modern, .markI] { + for curve in 0...3 { + for l4 in [0, 60, 99] { + for ol in [55, 99] { + let s = SynthEngine() + s.setSampleRate(48000) + s.setFMEngine(mode) + s.setMonoPerformance(enabled: true, portamentoMode: .fingered, glissando: false) + let ops = (0..<6).map { i in + i == 5 ? DX7OperatorPreset(outputLevel: ol, egRate1: 75, egRate2: 65, egRate3: 70, + egRate4: 45, egLevel1: 99, egLevel2: 90, egLevel3: 75, egLevel4: l4, + klsBreakPoint: 39, klsLeftDepth: 60, klsRightDepth: 60, + klsLeftCurve: curve, klsRightCurve: curve) + : DX7OperatorPreset(outputLevel: 0) + } + s.loadDX7Preset(DX7Preset(name: "Unedited", algorithm: 31, feedback: 0, operators: ops, category: .other)) + var hash = SHA256() + var left = [Float](repeating: 0, count: 64), right = left + func render(_ blocks: Int) { + for _ in 0.. Date: Tue, 29 Sep 2026 15:35:09 +0900 Subject: [PATCH 4/5] chore: prepare Core 1.22.1 (M2DX #112, #119) --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 695ba0d..d8aae25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.22.1] - 2026-09-29 + ### Fixed - **Host sample-rate publication (M2DX #119).** `setSampleRate` uses an independent atomic value so host resource allocation can overlap UI parameter edits without From 36214f018f16646141a8aed75e9bcc9780643a25 Mon Sep 17 00:00:00 2001 From: Hakaru Hirose Date: Tue, 29 Sep 2026 17:25:29 +0900 Subject: [PATCH 5/5] fix: isolate Mark I depth in each engine snapshot (M2DX #119) --- CHANGELOG.md | 3 + Sources/M2DXCore/Engine/DX7MarkI.swift | 30 +++++---- Sources/M2DXCore/Engine/DX7Voice.swift | 8 ++- .../M2DXCore/Engine/ParameterSnapshot.swift | 1 + Sources/M2DXCore/Engine/SynthEngine.swift | 17 +++--- ...arkICalibrationCharacterizationTests.swift | 4 +- .../MarkIDepthIsolationTests.swift | 61 +++++++++++++++++++ Tests/M2DXCoreTests/MarkIDivisorABTests.swift | 6 +- docs/API.md | 1 + 9 files changed, 101 insertions(+), 30 deletions(-) create mode 100644 Tests/M2DXCoreTests/MarkIDepthIsolationTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index d8aae25..6a72081 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [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, diff --git a/Sources/M2DXCore/Engine/DX7MarkI.swift b/Sources/M2DXCore/Engine/DX7MarkI.swift index 3b05e85..3e78d28 100644 --- a/Sources/M2DXCore/Engine/DX7MarkI.swift +++ b/Sources/M2DXCore/Engine/DX7MarkI.swift @@ -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. @@ -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) @@ -93,11 +94,12 @@ private func attenRamp(_ a1: UInt16, _ a2: UInt16, _ n: Int) -> Int32 { @inline(__always) func computeModMkI(_ output: UnsafeMutablePointer, _ input: UnsafePointer, - 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.., _ 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 @@ -154,7 +157,7 @@ func computeFb2MkI(_ output: UnsafeMutablePointer, _ 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 } @@ -164,7 +167,8 @@ func computeFb2MkI(_ output: UnsafeMutablePointer, _ p: inout MarkIChainP @inline(__always) func computeFb3MkI(_ output: UnsafeMutablePointer, _ 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 @@ -174,8 +178,8 @@ func computeFb3MkI(_ output: UnsafeMutablePointer, _ 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 diff --git a/Sources/M2DXCore/Engine/DX7Voice.swift b/Sources/M2DXCore/Engine/DX7Voice.swift index 7a2e544..27a8950 100644 --- a/Sources/M2DXCore/Engine/DX7Voice.swift +++ b/Sources/M2DXCore/Engine/DX7Voice.swift @@ -112,6 +112,7 @@ package struct DX7Voice { 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 // MIDI 2.0 Per-Note state var perNotePitchBendFactor: Float = 1.0 @@ -570,7 +571,7 @@ package struct DX7Voice { levelIn: (ops.0.levelIn, ops.1.levelIn, 0) ) computeFb2MkI(output, &p, atten01: atten1, atten02: atten2, - fbBuf: &fbBuf, fbShift: fbShiftMkI) + fbBuf: &fbBuf, fbShift: fbShiftMkI, modScaleQ12: markIModScaleQ12) ops.0.fbBuf = fbBuf ops.0.phase = p.phase.0 ops.1.phase = p.phase.1 @@ -588,7 +589,7 @@ package struct DX7Voice { levelIn: (ops.0.levelIn, ops.1.levelIn, ops.2.levelIn) ) computeFb3MkI(output, &p, atten01: atten1, atten02: atten2, - fbBuf: &fbBuf, fbShift: fbShiftMkI) + fbBuf: &fbBuf, fbShift: fbShiftMkI, modScaleQ12: markIModScaleQ12) ops.0.fbBuf = fbBuf ops.0.phase = p.phase.0 ops.1.phase = p.phase.1 @@ -632,7 +633,8 @@ package struct DX7Voice { if inbus == 1 { inptr = UnsafePointer(bus1) } else { inptr = UnsafePointer(bus2) } computeModMkI(outptr, inptr, phase0: opPhase, freq: opFreq, - atten1: atten1, atten2: atten2, add: shouldAdd, n: blockSize) + atten1: atten1, atten2: atten2, add: shouldAdd, n: blockSize, + modScaleQ12: markIModScaleQ12) } switch outbus { diff --git a/Sources/M2DXCore/Engine/ParameterSnapshot.swift b/Sources/M2DXCore/Engine/ParameterSnapshot.swift index 9b00856..0451826 100644 --- a/Sources/M2DXCore/Engine/ParameterSnapshot.swift +++ b/Sources/M2DXCore/Engine/ParameterSnapshot.swift @@ -146,6 +146,7 @@ public struct SynthParamSnapshot: Sendable { /// final mixed output when the Mark I engine is active. public var vintageDAC12bit: UInt8 = 0 public var fmEngine: UInt8 = 0 + public var markIModScaleQ12: Int32 = 512 public var masterTuning: Int16 = 0 public var unisonCount: Int = 1 public var unisonDetune: Float = 0 diff --git a/Sources/M2DXCore/Engine/SynthEngine.swift b/Sources/M2DXCore/Engine/SynthEngine.swift index d299517..707fbf0 100644 --- a/Sources/M2DXCore/Engine/SynthEngine.swift +++ b/Sources/M2DXCore/Engine/SynthEngine.swift @@ -665,16 +665,17 @@ public final class SynthEngine: @unchecked Sendable { /// Mark I forward-modulation depth as a divisor ÷X (larger = darker). The /// engine stores it as a Q12 scale (4096/X), so 0.1 steps stay distinct - /// across the whole ÷2…÷16 range. Read by the Mark I kernels on the render - /// thread; a plain aligned-Int32 write is atomic, and the value only changes - /// when the user moves the depth control. + /// across the whole ÷2…÷16 range. Like the other UI setters, publication goes + /// through this engine's parameter snapshot, including beginBatch/endBatch. + /// Instances never share the setting, and render sees a stable per-voice value. public func setMarkIModDivisor(_ divisor: Double) { - let d = max(0.5, divisor) - markIModScaleQ12 = Int32(max(1, min(4096, (4096.0 / d).rounded()))) + let d = divisor.isFinite ? max(0.5, divisor) : 8 + shadowSnapshot.markIModScaleQ12 = Int32(max(1, min(4096, (4096.0 / d).rounded()))) + bumpVersion() } - /// Test-only read of the current Mark I Q12 modulation scale. - public var debugMarkIModScaleQ12: Int32 { markIModScaleQ12 } + /// Test-only UI-lane read of this engine's requested Mark I Q12 modulation scale. + public var debugMarkIModScaleQ12: Int32 { shadowSnapshot.markIModScaleQ12 } /// Test-only read of the shadow snapshot's engine selection. public var debugShadowFMEngine: UInt8 { shadowSnapshot.fmEngine } @@ -1335,6 +1336,7 @@ public final class SynthEngine: @unchecked Sendable { let slotIdx = voicesDX7[i].slotId let slot = slotIdx < snapshot.activeSlotCount ? snapshot.slot(at: slotIdx) : snapshot.slot(at: 0) voicesDX7[i].algorithm = slot.algorithm + voicesDX7[i].markIModScaleQ12 = snapshot.markIModScaleQ12 voicesDX7[i].feedbackShiftValue = feedbackShift(Int(slot.ops.0.feedback * 7.0 + 0.5)) voicesDX7[i].applyParams(slot.ops.0, opIndex: 0) voicesDX7[i].applyParams(slot.ops.1, opIndex: 1) @@ -1751,6 +1753,7 @@ public final class SynthEngine: @unchecked Sendable { voicesDX7[target].algorithm = slot.algorithm voicesDX7[target].engineMode = currentFMEngine + voicesDX7[target].markIModScaleQ12 = snapshot.markIModScaleQ12 voicesDX7[target].slotId = slotIdx // #76: capture a deterministic random pan at note-on (random mode). if snapshot.config(at: slotIdx).panRandom { diff --git a/Tests/M2DXCoreTests/MarkICalibrationCharacterizationTests.swift b/Tests/M2DXCoreTests/MarkICalibrationCharacterizationTests.swift index 402e0a4..6705148 100644 --- a/Tests/M2DXCoreTests/MarkICalibrationCharacterizationTests.swift +++ b/Tests/M2DXCoreTests/MarkICalibrationCharacterizationTests.swift @@ -3,7 +3,7 @@ // Renders ONE pure carrier (alg 32, op0 only — no modulation) at a sweep of output levels for // Modern vs Mark I and prints the raw-peak gain curve + Mark I/Modern ratio. This decides whether // finding 1 is a constant 4× offset (constant-shift fixable) or a level-dependent curve divergence. -// Not a pass/fail behavior test. Env-gated (mutates the process-global markIModScaleQ12). +// Not a pass/fail behavior test. Env-gated because it renders investigation data. // Run: `M2DX_MARKI_CHAR=1 swift test --filter renderCarrierGainCurve` import Testing @@ -60,7 +60,6 @@ struct MarkICalibrationCharacterizationTests { print("MarkICalibrationCharacterizationTests skipped — set M2DX_MARKI_CHAR=1 to render the curve") return } - defer { SynthEngine().setMarkIModDivisor(8.0) } // restore process-global default let levels: [Float] = [0.1, 0.2, 0.35, 0.5, 0.7, 0.85, 1.0] print("level, modernPeak, markIDiv5Peak, ratio(markI/modern)") @@ -350,7 +349,6 @@ struct MarkICalibrationCharacterizationTests { print("MarkICalibrationCharacterizationTests skipped — set M2DX_MARKI_CHAR=1 to render the curve") return } - defer { SynthEngine().setMarkIModDivisor(8.0) } // restore process-global default // Finding 3 ("Mark I feedback 4× too strong") shows up in BRIGHTNESS more than peak: // Mark I re-injects a 4×-larger internal feedback signal → brighter timbre even when diff --git a/Tests/M2DXCoreTests/MarkIDepthIsolationTests.swift b/Tests/M2DXCoreTests/MarkIDepthIsolationTests.swift new file mode 100644 index 0000000..764c874 --- /dev/null +++ b/Tests/M2DXCoreTests/MarkIDepthIsolationTests.swift @@ -0,0 +1,61 @@ +import Testing +@testable import M2DXCore + +@Suite("Mark I depth belongs to each engine (M2DX #119)", .serialized) +struct MarkIDepthIsolationTests { + private func make(_ divisor: Double, algorithm: Int) -> SynthEngine { + let engine = SynthEngine() + engine.setFMEngine(.markI) + engine.setMarkIModDivisor(divisor) + engine.setMasterVolume(0.2) + engine.setAlgorithm(algorithm) + engine.setOperatorFeedback(7) + for i in 0..<6 { + engine.setOperatorDX7OutputLevel(i, level: 85) + engine.setOperatorRatio(i, ratio: Float(i + 1)) + engine.setOperatorDX7EGRates(i, r1: 99, r2: 99, r3: 99, r4: 99) + engine.setOperatorDX7EGLevels(i, l1: 99, l2: 99, l3: 99, l4: 0) + } + _ = render(engine, blocks: 1) + engine.sendMIDI(MIDIEvent(kind: .noteOn, data1: 60, data2: UInt32(100) << 9)) + return engine + } + + private func render(_ engine: SynthEngine, blocks: Int = 32) -> [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 + } + + @Test("another instance cannot change the sounding depth, including fused kernels", + arguments: [0, 3, 5]) + func independentEngines(algorithm: Int) { + let expected = render(make(2, algorithm: algorithm)) + let first = make(2, algorithm: algorithm) + let other = make(16, algorithm: algorithm) + #expect(first.debugMarkIModScaleQ12 == 2048) + #expect(other.debugMarkIModScaleQ12 == 256) + let unchanged = render(first) == expected + #expect(unchanged, "Creating/editing another AU must not change this voice") + let different = render(other) + #expect(zip(different, expected).contains { abs($0 - $1) > 1e-5 }, "Depth must still affect audio") + } + + @Test("depth edits wait for their editor batch, like the other voice parameters") + func batchPublication() { + let control = make(2, algorithm: 0), edited = make(2, algorithm: 0) + #expect(render(edited) == render(control)) + let expectedDuringBatch = render(control) + edited.beginBatch() + edited.setMarkIModDivisor(16) + let duringBatch = render(edited) == expectedDuringBatch + #expect(duringBatch, "An open batch must not leak a depth edit") + edited.endBatch() + let changed = render(edited) != render(control) + #expect(changed, "The completed batch must reach the sounding voice") + } +} diff --git a/Tests/M2DXCoreTests/MarkIDivisorABTests.swift b/Tests/M2DXCoreTests/MarkIDivisorABTests.swift index 5030b4f..f2e8f8e 100644 --- a/Tests/M2DXCoreTests/MarkIDivisorABTests.swift +++ b/Tests/M2DXCoreTests/MarkIDivisorABTests.swift @@ -91,15 +91,13 @@ struct MarkIDivisorABTests { @Test("Render Mark I divisor A/B WAVs (BASS 1 + E.PIANO 1)") func renderMarkIDivisorAB() throws { - // Investigation harness: it mutates the process-global `markIModScaleQ12` (via - // setMarkIModDivisor), which races the Mark I parity suite under Swift Testing's parallel - // runner. Gate it behind an env var so a normal `swift test` is a no-op; render with: + // Investigation harness writes audition WAVs; keep normal tests free of artifacts. + // Depth is per-engine; no global calibration state needs restoration. Render with: // M2DX_MARKI_AB=1 swift test --filter renderMarkIDivisorAB guard ProcessInfo.processInfo.environment["M2DX_MARKI_AB"] != nil else { print("MarkIDivisorABTests skipped — set M2DX_MARKI_AB=1 to render the A/B WAVs") return } - defer { SynthEngine().setMarkIModDivisor(8.0) } // restore the global default (÷8) let dir = URL(fileURLWithPath: "/tmp/m2dx-marki-ab") try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) diff --git a/docs/API.md b/docs/API.md index fa942bb..0a3a44f 100644 --- a/docs/API.md +++ b/docs/API.md @@ -66,6 +66,7 @@ Enqueue a MIDI event from the UI thread. Lock-free, allocation-free. |--------|-------|-------------| | `setSampleRate(_ sr: Float)` | 8000–192000 | Audio sample rate in Hz; values ≥ 1 are clamped, invalid/non-finite values fall back to 44100. Callable from a host lifecycle thread. | | `setMasterVolume(_ vol: Float)` | 0.0–1.0 | Master output volume | +| `setMarkIModDivisor(_ divisor: Double)` | ≥ 0.5 | Per-engine Mark I depth divisor (default 8); non-finite values use 8. Published with the UI parameter batch. | | `setMasterTuning(_ cents: Int16)` | -100–100 | Master fine tuning in cents | | `setAlgorithm(_ alg: Int)` | 0–31 | FM algorithm (0-indexed) | | `setOversamplingMode(_ mode: OversamplingMode)` | — | Oversampling quality |