Forwarded from hakaru/M2DX issue #110 — a Claude performance audit (high-voice-count focus, read-only) of the M2DX app found three per-voice cost findings that live entirely in this repo (SynthEngine.swift), not in the app.
⚠️ RT-critical / Dexed-verified code. Past LLM-suggested RT refactors (tuple→Array, removing @inline(__always)) have introduced regressions here. Any fix needs a careful, minimal change plus keeping the existing steal-order-pinning tests green.
Voice count model
Per-key max = LAYER(≤8) × Voice Stack(≤16) × unison(≤8). Cap is effectiveMaxVoices = min(2048, min(128, base×unison) × stack), overall kMaxVoices = 2048, round-robin voice stealing. The app sends exactly one MIDI event per key press (no main-thread fan-out); all expansion happens inside this repo's audio-thread render path.
Findings
1. [high] note-on free-voice search is O(N²)
SynthEngine.swift:1439-1453 (inside the for s in voiceStack { for u in unison { … } } expansion, lines 1418-1539)
- Each voice generated by one key-press does a linear scan for a free slot (
for i in 0..<maxV { checkActive(); if !active break }). As the pool fills, each scan gets longer — one key-press generating N voices costs ≈ N²/2 checkActive() probes. Runs on the audio thread (doNoteOn is called from drainMIDI() inside render()).
- Trigger: dense chords with high unison×stack×LAYER — hundreds to 1024 allocations per key-press, each scanning up to
maxV (≤2048). Can blow the render deadline → dropouts.
- Direction: a free-list / next-free cursor to make the search O(1) — must not break the tests that pin voice-steal order. Voice buffers are already allocated once in init; no new allocation needed.
2. [medium] parameter re-apply scans the full kMaxVoices=2048 on every snapshot change
SynthEngine.swift:1061-1072 — on every snapshot-version bump: for i in 0..<kMaxVoices { … 6× applyParams }
- A knob/CC/automation change bumps the snapshot version, and the next render buffer applies params to all 2048 slots (not
effectiveMaxVoices) across 6 operators each. Independent of actual voice count — scales with automation/knob density. Issue #89's kMaxVoices bump from 128→2048 made this loop ~16× worse.
- Direction: bound the apply loop to
effectiveMaxVoices — the render loop already does this correctly elsewhere (lines 1198/1261/1331/1344).
3. [low] render's pan/checkActive loop scans the full cap including inactive voices
🤖 Forwarded from a Claude performance audit (2026-07-13) of the M2DX app repo. Original: hakaru/M2DX#110.
Forwarded from
hakaru/M2DXissue #110 — a Claude performance audit (high-voice-count focus, read-only) of the M2DX app found three per-voice cost findings that live entirely in this repo (SynthEngine.swift), not in the app.Voice count model
Per-key max = LAYER(≤8) × Voice Stack(≤16) × unison(≤8). Cap is
effectiveMaxVoices = min(2048, min(128, base×unison) × stack), overallkMaxVoices = 2048, round-robin voice stealing. The app sends exactly one MIDI event per key press (no main-thread fan-out); all expansion happens inside this repo's audio-thread render path.Findings
1. [high] note-on free-voice search is O(N²)
SynthEngine.swift:1439-1453(inside thefor s in voiceStack { for u in unison { … } }expansion, lines 1418-1539)for i in 0..<maxV { checkActive(); if !active break }). As the pool fills, each scan gets longer — one key-press generating N voices costs ≈ N²/2checkActive()probes. Runs on the audio thread (doNoteOnis called fromdrainMIDI()insiderender()).maxV(≤2048). Can blow the render deadline → dropouts.2. [medium] parameter re-apply scans the full
kMaxVoices=2048on every snapshot changeSynthEngine.swift:1061-1072— on every snapshot-version bump:for i in 0..<kMaxVoices { … 6× applyParams }effectiveMaxVoices) across 6 operators each. Independent of actual voice count — scales with automation/knob density. Issue #89'skMaxVoicesbump from 128→2048 made this loop ~16× worse.effectiveMaxVoices— the render loop already does this correctly elsewhere (lines 1198/1261/1331/1344).3. [low] render's pan/checkActive loop scans the full cap including inactive voices
SynthEngine.swift:1201(for i in 0..<maxV { checkActive() }),:1227-1241(pan-gain loop has no.activeguard, doescosf+sinfper slot whenpan != 0)effectiveMaxVoices) per render buffer. Unlike Real-time safety violation: MIDI handling on audio thread (Allocation & Locks) #1, already usesmaxV(≤2048), notkMaxVoices. Minor next to actual per-voice DSP cost.🤖 Forwarded from a Claude performance audit (2026-07-13) of the M2DX app repo. Original: hakaru/M2DX#110.