From 9715bee6213320b62b56cd350bb7004b15ac10e0 Mon Sep 17 00:00:00 2001 From: Hakaru Hirose Date: Wed, 30 Sep 2026 15:29:32 +0900 Subject: [PATCH] feat: process host events after render configuration (M2DX #119) --- CHANGELOG.md | 15 ++ Sources/M2DXCore/Engine/SynthEngine.swift | 250 +++++++++++------- .../M2DXCoreTests/RenderEventPhaseTests.swift | 178 +++++++++++++ docs/API.md | 19 ++ docs/render-event-phase.md | 18 ++ 5 files changed, 379 insertions(+), 101 deletions(-) create mode 100644 Tests/M2DXCoreTests/RenderEventPhaseTests.swift create mode 100644 docs/render-event-phase.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a72081..3e55171 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.23.0] - 2026-09-30 + +### Added +- **Synchronous host render events (M2DX #119).** A nonescaping render callback + runs after control snapshots and resets, before audio generation. Hosts can + retain automation across unrelated control edits and process their ordered + MIDI/parameter events directly without consuming MIDI FIFO capacity. + Parameter master volume stays separate from physical MIDI CC7 volume. + +### Fixed +- **First note after a render configuration change (M2DX #119).** FM engine, + oversampling, sample rate and voice-budget preparation now precede direct and + queued note events. The first note after switching modes stays sounding, and + expanded LAYER/unison/Voice Stack settings allocate their full voice count. + ## [1.22.1] - 2026-09-29 ### Fixed diff --git a/Sources/M2DXCore/Engine/SynthEngine.swift b/Sources/M2DXCore/Engine/SynthEngine.swift index 707fbf0..5dcc49f 100644 --- a/Sources/M2DXCore/Engine/SynthEngine.swift +++ b/Sources/M2DXCore/Engine/SynthEngine.swift @@ -307,38 +307,60 @@ public final class SynthEngine: @unchecked Sendable { private func drainMIDI() { while let event = midiRing.pop() { - switch event.kind { - case .noteOn: - let vel16 = UInt16(event.data2 & 0xFFFF) - if vel16 == 0 { doNoteOff(event.data1) } - else { doNoteOn(event.data1, velocity16: vel16) } - case .noteOff: - doNoteOff(event.data1) - case .controlChange: - doControlChange(event.data1, value32: event.data2) - case .pitchBend: - doPitchBend32(event.data2) - case .channelPressure: - doChannelPressure(event.data2) - case .polyPressure: - doPolyPressure(event.data1, value32: event.data2) - case .perNotePitchBend: - doPerNotePitchBend(event.data1, value32: event.data2) - case .perNoteCC: - let index = UInt8(event.data2 >> 24) - let value = event.data2 & 0x00FFFFFF - doPerNoteCC(event.data1, index: index, value: value) - case .perNoteManagement: - doPerNoteManagement(event.data1, flags: event.data2) - case .registeredController: - let index = UInt8(event.data2 >> 24) - let value = event.data2 & 0x00FFFFFF - doRPN(event.data1, index: index, value: value) - case .assignableController: - let index = UInt8(event.data2 >> 24) - let value = event.data2 & 0x00FFFFFF - doNRPN(event.data1, index: index, value: value) - } + processRenderMIDI(event) + } + } + + /// Process a host MIDI event immediately from the synchronous render-event callback. + /// Audio thread only, after this block's control snapshot/reset has been consumed. + /// Unlike `sendMIDI`, this never writes the FIFO; keep one ordered host stream here. + public func processRenderMIDI(_ event: MIDIEvent) { + switch event.kind { + case .noteOn: + let vel16 = UInt16(event.data2 & 0xFFFF) + if vel16 == 0 { doNoteOff(event.data1) } + else { doNoteOn(event.data1, velocity16: vel16) } + case .noteOff: + doNoteOff(event.data1) + case .controlChange: + doControlChange(event.data1, value32: event.data2) + case .pitchBend: + doPitchBend32(event.data2) + case .channelPressure: + doChannelPressure(event.data2) + case .polyPressure: + doPolyPressure(event.data1, value32: event.data2) + case .perNotePitchBend: + doPerNotePitchBend(event.data1, value32: event.data2) + case .perNoteCC: + let index = UInt8(event.data2 >> 24) + let value = event.data2 & 0x00FFFFFF + doPerNoteCC(event.data1, index: index, value: value) + case .perNoteManagement: + doPerNoteManagement(event.data1, flags: event.data2) + case .registeredController: + let index = UInt8(event.data2 >> 24) + let value = event.data2 & 0x00FFFFFF + doRPN(event.data1, index: index, value: value) + case .assignableController: + let index = UInt8(event.data2 >> 24) + let value = event.data2 & 0x00FFFFFF + doNRPN(event.data1, index: index, value: value) + } + } + + /// Process host *parameter automation* in the render-event callback. NRPNs use the + /// existing native parameter transport. Parameter CC7 controls snapshot master volume, + /// not the independent channel-volume multiplier used by physical MIDI CC7. + /// Other event kinds are ignored: a parameter event cannot allocate or release a note. + public func processRenderParameter(_ event: MIDIEvent) { + switch event.kind { + case .assignableController: + doNRPN(event.data1, index: UInt8(event.data2 >> 24), value: event.data2 & 0x00FF_FFFF) + case .controlChange where event.data1 == 7: + currentSnapshot.masterVolume = Float(Double(event.data2) / Double(UInt32.max)) + automationDirty = true + default: break } } @@ -1196,14 +1218,101 @@ public final class SynthEngine: @unchecked Sendable { _voiceAllocationProbeCount.store(voiceAllocationProbeCountRT, ordering: .relaxed) } + /// Prepare configuration that can retire voices or change note-on allocation before + /// host/queued events create voices. Render parameter events cannot change these fields. + private func prepareRenderConfiguration(_ snapshot: SynthParamSnapshot) { + let newOSMode = OversamplingMode(rawValue: snapshot.oversamplingMode) ?? .off + if newOSMode != currentOversamplingMode { + currentOversamplingMode = newOSMode + doAllNotesOff() + let factor: Float = (newOSMode == .off) ? 1.0 : 2.0 + baseSampleRate = snapshot.sampleRate + sampleRate = baseSampleRate * factor + for i in 0.. 0 { voicesDX7[i].finishFadeOut() } // #116 + voicesDX7[i].active = false + voicesDX7[i].engineMode = newFMEngine + } + resetVoiceAllocator() + } + + let newTimbreMode = TimbreMode(rawValue: snapshot.timbreMode) ?? .single + currentTimbreMode = newTimbreMode + + let voicesForMode: Int + switch currentTimbreMode { + case .single: voicesForMode = 16 + case .dual, .split: voicesForMode = 32 + case .tx816: voicesForMode = 64 + case .layer: voicesForMode = kLayerBaseVoices // #89: was kMaxVoices; decoupled so LAYER stays 128 while the buffer grows. + } + let baseVoices = (currentOversamplingMode == .off) ? voicesForMode : max(8, voicesForMode / 2) + // #89: preserve the pre-Voice-Stack budget exactly — min(legacy 128 cap, base×unison) — + // so LAYER/dual + unison≥2 at stack=1 stay byte-identical to pre-#89 (spec §2). The + // Voice Stack multiplier then scales that up to the kMaxVoices (2048) buffer. + let preStackVoices = min(kLayerBaseVoices, baseVoices * max(1, snapshot.unisonCount)) + effectiveMaxVoices = min(kMaxVoices, preStackVoices * max(1, snapshot.voiceStackMultiplier)) + + // Reap voices outside the (possibly shrunken) active range. The render + // loop only runs checkActive() within effectiveMaxVoices, so a voice + // allocated at a higher index under a larger mode would otherwise keep + // its `active` flag set forever (and could resurrect on a later grow). + if effectiveMaxVoices < kMaxVoices { + for i in effectiveMaxVoices.. 0 { voicesDX7[i].finishFadeOut() } // #116 + voicesDX7[i].noteOff() + voicesDX7[i].active = false + voicesDX7[i].sustained = false + markVoiceFree(i) + } + } + if nextFreeVoiceSearchIndex >= effectiveMaxVoices { + nextFreeVoiceSearchIndex = 0 + } + } + // MARK: - Render (audio thread) public func render(into bufferL: UnsafeMutablePointer, bufferR: UnsafeMutablePointer, frameCount: Int) { + render(into: bufferL, bufferR: bufferR, frameCount: frameCount, processEvents: { _ in }) + } + + /// Render with a synchronous, nonescaping host event phase. The callback runs after the + /// new control snapshot, mode changes and controller reset, before queued MIDI and audio. + /// Its argument is true only when a control snapshot was consumed. A host can reapply its + /// still-active automation then, followed by this block's ordered MIDI/parameter events. + /// The callback must obey the render-thread rules: no allocation, locks or control setters. + public func render(into bufferL: UnsafeMutablePointer, + bufferR: UnsafeMutablePointer, + frameCount: Int, + processEvents: (_ controlSnapshotChanged: Bool) -> Void) { // Pop latest snapshot first so MIDI handlers see current params + var controlSnapshotChanged = false if let newSnapshot = snapshotRing.popLatest() { currentSnapshot = newSnapshot + controlSnapshotChanged = true } let requestedSampleRate = Float(bitPattern: requestedSampleRateBits.load(ordering: .relaxed)) if currentSnapshot.sampleRate != requestedSampleRate { @@ -1238,10 +1347,18 @@ public final class SynthEngine: @unchecked Sendable { performControllerReset() } + if currentSnapshot.version != appliedVersion || automationDirty { + prepareRenderConfiguration(currentSnapshot) + } + // Reap envelope-complete voices before note-on allocation. This replaces // the old per-note linear checkActive scan with one bounded pass per render. reapFinishedVoices() + // The host can restore retained automation only after the snapshot overwrite above, + // and deliver current events after pending resets. No extra MIDI FIFO traffic. + processEvents(controlSnapshotChanged) + // Drain MIDI before applyParams so allNotesOff is processed // before intermediate preset change snapshots corrupt active voices. drainMIDI() @@ -1258,78 +1375,9 @@ public final class SynthEngine: @unchecked Sendable { appliedVersion = snapshot.version appliedCount &+= 1 - let newOSMode = OversamplingMode(rawValue: snapshot.oversamplingMode) ?? .off - if newOSMode != currentOversamplingMode { - currentOversamplingMode = newOSMode - doAllNotesOff() - let factor: Float = (newOSMode == .off) ? 1.0 : 2.0 - baseSampleRate = snapshot.sampleRate - sampleRate = baseSampleRate * factor - for i in 0.. 0 { voicesDX7[i].finishFadeOut() } // #116 - voicesDX7[i].active = false - voicesDX7[i].engineMode = newFMEngine - } - resetVoiceAllocator() - } - algorithm = snapshot.algorithm masterVolume = snapshot.masterVolume - let newTimbreMode = TimbreMode(rawValue: snapshot.timbreMode) ?? .single - currentTimbreMode = newTimbreMode - - let voicesForMode: Int - switch currentTimbreMode { - case .single: voicesForMode = 16 - case .dual, .split: voicesForMode = 32 - case .tx816: voicesForMode = 64 - case .layer: voicesForMode = kLayerBaseVoices // #89: was kMaxVoices; decoupled so LAYER stays 128 while the buffer grows. - } - let baseVoices = (currentOversamplingMode == .off) ? voicesForMode : max(8, voicesForMode / 2) - // #89: preserve the pre-Voice-Stack budget exactly — min(legacy 128 cap, base×unison) — - // so LAYER/dual + unison≥2 at stack=1 stay byte-identical to pre-#89 (spec §2). The - // Voice Stack multiplier then scales that up to the kMaxVoices (2048) buffer. - let preStackVoices = min(kLayerBaseVoices, baseVoices * max(1, snapshot.unisonCount)) - effectiveMaxVoices = min(kMaxVoices, preStackVoices * max(1, snapshot.voiceStackMultiplier)) - - // Reap voices outside the (possibly shrunken) active range. The render - // loop only runs checkActive() within effectiveMaxVoices, so a voice - // allocated at a higher index under a larger mode would otherwise keep - // its `active` flag set forever (and could resurrect on a later grow). - if effectiveMaxVoices < kMaxVoices { - for i in effectiveMaxVoices.. 0 { voicesDX7[i].finishFadeOut() } // #116 - voicesDX7[i].noteOff() - voicesDX7[i].active = false - voicesDX7[i].sustained = false - markVoiceFree(i) - } - } - if nextFreeVoiceSearchIndex >= effectiveMaxVoices { - nextFreeVoiceSearchIndex = 0 - } - // Apply per-voice params unconditionally. // Preset loads use loadDX7Preset (atomic 1-push), so versionDelta is always 1. for i in 0.. MIDIEvent { + let fixed = UInt32(bitPattern: Int32((value * 256).rounded())) & 0x00ff_ffff + return MIDIEvent(kind: .assignableController, data1: bank, data2: UInt32(index) << 24 | fixed) + } + + private func render(_ engine: SynthEngine, frames: Int = 64, + events: (Bool) -> Void = { _ in }) -> [Float] { + var left = [Float](repeating: 0, count: frames), right = left + left.withUnsafeMutableBufferPointer { l in + right.withUnsafeMutableBufferPointer { r in + engine.render(into: l.baseAddress!, bufferR: r.baseAddress!, frameCount: frames, + processEvents: events) + } + } + return left + } + + private func configured(_ mode: FMEngine) -> SynthEngine { + let engine = SynthEngine() + engine.loadDX7Preset(DX7FactoryPresets.initVoice) + engine.setFMEngine(mode) + return engine + } + + @Test("Host automation survives unrelated snapshots and explicit replacement restores control", + arguments: [FMEngine.modern, .markI]) + func keepAutomation(_ mode: FMEngine) { + let host = configured(mode), reference = configured(mode) + host.setOperatorDetune(5, cents: 37) + reference.setOperatorDetune(5, cents: -4) + let detune = parameter(5, 2, -4) + let note = MIDIEvent(kind: .noteOn, data1: 60, data2: 60_000) + let first = render(host) { changed in + #expect(changed) + host.processRenderParameter(detune) + host.processRenderMIDI(note) + } + let firstReference = render(reference) { _ in reference.processRenderMIDI(note) } + #expect(host.debugActiveVoiceCount == 1) + #expect(reference.debugActiveVoiceCount == 1) + #expect(first.contains { abs($0) > 0.000001 }) + #expect(firstReference.contains { abs($0) > 0.000001 }) + #expect(zip(first, firstReference).allSatisfy { abs($0 - $1) < 0.00001 }) + host.setAlgorithm(17); reference.setAlgorithm(17) + let edited = render(host) { changed in + #expect(changed) + host.processRenderParameter(detune) + } + let editedReference = render(reference) + #expect(host.debugCurrentSnapshot.slots.0.ops.5.detuneCents == -4) + #expect(zip(edited, editedReference).allSatisfy { abs($0 - $1) < 0.00001 }) + let applied = host.debugApplyCount + for _ in 0..<4 { + _ = render(host) { changed in #expect(!changed) } + _ = render(reference) + } + #expect(host.debugApplyCount == applied, "unchanged blocks must not repeat the heavy voice apply") + // The host invalidates that address's override when an explicit control write follows. + host.setOperatorDetune(5, cents: 6); reference.setOperatorDetune(5, cents: 6) + let replaced = render(host) { changed in #expect(changed) } + let replacedReference = render(reference) + #expect(host.debugCurrentSnapshot.slots.0.ops.5.detuneCents == 6) + #expect(zip(replaced, replacedReference).allSatisfy { abs($0 - $1) < 0.00001 }) + } + + @Test("Render events run after pending controller reset, in caller order") + func sustainOrder() { + let engine = configured(.modern) + _ = render(engine) { _ in + engine.processRenderMIDI(.init(kind: .noteOn, data1: 60, data2: 60_000)) + engine.processRenderMIDI(.init(kind: .controlChange, data1: 64, data2: .max)) + engine.processRenderMIDI(.init(kind: .noteOff, data1: 60, data2: 0)) + } + let held = render(engine, frames: 8192) + #expect(engine.debugActiveVoiceCount == 1) + #expect(held.suffix(1024).contains { abs($0) > 0.001 }) + _ = render(engine, frames: 8192) { _ in + engine.processRenderMIDI(.init(kind: .controlChange, data1: 64, data2: 0)) + } + _ = render(engine, frames: 8192) + #expect(engine.debugActiveVoiceCount == 0) + } + + @Test("Host parameter volume and physical MIDI CC7 remain independent", + arguments: [FMEngine.modern, .markI]) + func volumeSemantics(_ mode: FMEngine) { + let host = configured(mode), reference = configured(mode) + host.setMasterVolume(0.7); reference.setMasterVolume(0.5) + let ccVolume = MIDIEvent(kind: .controlChange, data1: 7, data2: UInt32.max / 4) + let note = MIDIEvent(kind: .noteOn, data1: 60, data2: 60_000) + let actual = render(host) { _ in + host.processRenderParameter(.init(kind: .controlChange, data1: 7, data2: UInt32.max / 2)) + host.processRenderMIDI(ccVolume) + host.processRenderMIDI(note) + } + let expected = render(reference) { _ in + reference.processRenderMIDI(ccVolume) + reference.processRenderMIDI(note) + } + #expect(host.debugCurrentSnapshot.masterVolume == 0.5) + #expect(host.debugActiveVoiceCount == 1) + #expect(reference.debugActiveVoiceCount == 1) + #expect(actual.contains { abs($0) > 0.000001 }) + #expect(expected.contains { abs($0) > 0.000001 }) + #expect(zip(actual, expected).allSatisfy { abs($0 - $1) < 0.00001 }) + host.setMasterVolume(0.8); reference.setMasterVolume(0.8) + let replaced = render(host), replacedReference = render(reference) + #expect(zip(replaced, replacedReference).allSatisfy { abs($0 - $1) < 0.00001 }) + } + + @Test("The first note after an engine/oversampling switch uses the new mode and stays held", + arguments: [FMEngine.modern, .markI], + [OversamplingMode.off, .lowCPU, .highQuality]) + func firstNoteAfterModeChange(_ mode: FMEngine, _ oversampling: OversamplingMode) { + for queued in [false, true] { + let engine = configured(.modern) + _ = render(engine) + engine.setFMEngine(mode) + engine.setOversamplingMode(oversampling) + engine.setSampleRate(48_000) + let note = MIDIEvent(kind: .noteOn, data1: 60, data2: 60_000) + if queued { engine.sendMIDI(note) } + _ = render(engine) { _ in + if !queued { engine.processRenderMIDI(note) } + } + let held = render(engine, frames: 8192) + #expect(engine.debugActiveVoiceCount == 1, + "mode preparation must precede both direct and queued note-on") + #expect(held.suffix(1024).contains { abs($0) > 0.000001 }) + } + } + + @Test("The first layered note uses the expanded voice budget", + arguments: [FMEngine.modern, .markI]) + func firstNoteAfterBudgetChange(_ mode: FMEngine) { + for queued in [false, true] { + let engine = configured(mode) + _ = render(engine) + engine.setLayerPartition(parts: 8, unison: 4) + engine.setVoiceStackMultiplier(2) + let note = MIDIEvent(kind: .noteOn, data1: 60, data2: 60_000) + if queued { engine.sendMIDI(note) } + let audio = render(engine) { _ in + if !queued { engine.processRenderMIDI(note) } + } + #expect(engine.debugActiveVoiceCount == 64) + #expect(audio.contains { abs($0) > 0.000001 }) + } + } + + @Test("Render-side events do not consume the MIDI queue or apply parameters repeatedly") + func directEventsHaveNoQueueLoad() { + let engine = configured(.modern) + _ = render(engine) + let before = engine.debugApplyCount + _ = render(engine) { changed in + #expect(!changed) + for i in 0..<2_000 { + engine.processRenderParameter(parameter(0, 0, Float(i % 100))) + engine.processRenderMIDI(.init(kind: .polyPressure, data1: 60, data2: UInt32(i))) + } + } + #expect(engine.droppedMIDICount == 0) + #expect(engine.debugCurrentSnapshot.slots.0.ops.0.dx7OutputLevel == 99) + #expect(engine.debugApplyCount == before + 1) + _ = render(engine) { _ in + engine.processRenderParameter(.init(kind: .noteOn, data1: 60, data2: 60_000)) + engine.processRenderParameter(parameter(64, 5, 1)) // unsupported engine selector + } + #expect(engine.debugActiveVoiceCount == 0) + #expect(engine.debugApplyCount == before + 1) + } +} diff --git a/docs/API.md b/docs/API.md index 0a3a44f..c872d3c 100644 --- a/docs/API.md +++ b/docs/API.md @@ -52,6 +52,25 @@ Render audio frames into stereo buffers. Call from the audio thread only. - `frameCount`: Number of frames to render - Output is hard-clipped to [-1.0, 1.0] +#### Host render events (1.23.0+) + +```swift +public func render( + into bufferL: UnsafeMutablePointer, + bufferR: UnsafeMutablePointer, + frameCount: Int, + processEvents: (_ controlSnapshotChanged: Bool) -> Void +) +public func processRenderParameter(_ event: MIDIEvent) +public func processRenderMIDI(_ event: MIDIEvent) +``` + +The synchronous, nonescaping callback runs after the new control snapshot, pending mode/controller resets and completed-voice reclamation, before audio generation. `controlSnapshotChanged` is true only when this render consumed a new control snapshot. An AU host can reapply its still-active parameter automation at that point, then deliver its current event list in order. This prevents an unrelated control snapshot from erasing host automation without adding repeated events to the MIDI FIFO. + +Call the two `processRender*` methods **only from this callback on the audio thread**. They process events directly, without the MIDI FIFO. Parameter events support the existing assignable-controller/NRPN encoding and CC7; parameter CC7 changes synthesis master volume, while MIDI CC7 changes the independent channel-volume multiplier. Unsupported parameter event kinds are ignored. + +The callback must not allocate, block, access UI/main-actor state or call control setters. The engine does not retain it. Existing `sendMIDI` events drain after the callback; use one callback event stream when host MIDI/parameter order matters. The original three-argument `render` method remains available and drains its FIFO after configuration preparation, so the first queued note also uses the new engine and voice budget. + ### MIDI ```swift diff --git a/docs/render-event-phase.md b/docs/render-event-phase.md new file mode 100644 index 0000000..6eab551 --- /dev/null +++ b/docs/render-event-phase.md @@ -0,0 +1,18 @@ +# Host render event phase (M2DX #119) + +The AU host needs to retain its active automation after a new control snapshot arrives, then apply its current render events in order. Replaying retained automation into the MIDI FIFO on every block would consume note-event capacity and repeat the expensive voice parameter update. + +Add a synchronous, nonescaping `render(into:bufferR:frameCount:processEvents:)` overload. Its callback receives whether a control snapshot was consumed. It runs after snapshot, mode/controller reset and completed-voice reclamation, before voice parameter application and audio generation. The original render API remains available. + +The callback may call `processRenderParameter(_:)` and `processRenderMIDI(_:)`. These directly process events on the audio thread, without the FIFO. Parameter CC7 updates the synthesis master-volume field; physical MIDI CC7 remains the independent channel-volume multiplier. Host parameter NRPNs keep their existing native value transport. + +The host restores its still-active parameter values only when the callback reports a new control snapshot, then processes the current host event list in its original order. Explicit host edits invalidate the corresponding retained values before their new control snapshot arrives. Callers must use their control-side storage rather than read or write the engine's shadow from the audio thread. + +- [x] Add regression tests for both engines: retained detune vs control-only PCM reference, later explicit replacement, sustain/reset ordering, master-volume versus CC7, and events exceeding MIDI FIFO capacity without queue drops. +- [x] Implement the event phase and direct event entry points; retain the queue path for callers of the original API. Focused RenderEventPhase/NRPNAutomation tests pass (13 tests). +- [x] Prepare engine/oversampling/sample-rate/voice-budget changes before both event paths. Regression tests require audible PCM and active voices; the first LAYER note allocates all 64 requested voices. +- [x] Pass focused (13 tests) and full Debug/Release tests (349 tests, 62 suites each), then review independently. The mode-preparation order finding is resolved; no remaining P1/P2 found in the Core API review. +- [x] Document the public thread contract in the API reference. +- [ ] Publish a release before the app adopts the API. + +No callbacks may allocate, block, access main-actor state or call control setters. The callback's lifetime ends with `render`; the engine never stores it. Existing queued MIDI is drained after the callback, so callers requiring one ordered host event stream should deliver that stream through the callback only.