From 9dea8a7f5b72692e6c804d883ac2a57886438ac9 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Fri, 24 Jul 2026 18:01:45 -0700 Subject: [PATCH] Add independent Menu Band percussion volume --- .../Contents/Resources/en.lproj/index.html | 1 + .../Sources/MenuBand/MenuBandController.swift | 118 ++++++++++++++++-- .../Sources/MenuBand/MenuBandPercussion.swift | 16 ++- .../Sources/MenuBand/MenuBandSynth.swift | 7 ++ .../MenuBandTests/PercussionVolumeTests.swift | 41 ++++++ 5 files changed, 173 insertions(+), 10 deletions(-) create mode 100644 slab/menuband/Tests/MenuBandTests/PercussionVolumeTests.swift diff --git a/slab/menuband/Help/MenuBand.help/Contents/Resources/en.lproj/index.html b/slab/menuband/Help/MenuBand.help/Contents/Resources/en.lproj/index.html index a693204192..9169dce747 100644 --- a/slab/menuband/Help/MenuBand.help/Contents/Resources/en.lproj/index.html +++ b/slab/menuband/Help/MenuBand.help/Contents/Resources/en.lproj/index.html @@ -138,6 +138,7 @@ double-tap right ⌘Arm / disarm focused play hold `Record a microphone sample (with the SAMPLE cell active) [  ]Latch the left / right percussion halves + hold [ or ] + ↑ / ↓Adjust percussion volume (0–100%) diff --git a/slab/menuband/Sources/MenuBand/MenuBandController.swift b/slab/menuband/Sources/MenuBand/MenuBandController.swift index b9c400c738..afeed5baf9 100644 --- a/slab/menuband/Sources/MenuBand/MenuBandController.swift +++ b/slab/menuband/Sources/MenuBand/MenuBandController.swift @@ -116,6 +116,14 @@ final class MenuBandController { /// Display note each held drum key lit, so key-up extinguishes the /// right menubar cell (drum keys stay accent-lit for the whole hold). private var heldDrumDisplay: [UInt16: UInt8] = [:] + /// `[` / `]` double as momentary percussion-volume modifiers while held. + /// Arrow keys claimed during that hold remain claimed through key-up so + /// their release can never leak into the focused app after the bracket is + /// released first. + private var percussionControlKeysHeld: Set = [] + private var percussionVolumeArrowKeysHeld: Set = [] + private var percussionControlStartedEnabled: [UInt16: Bool] = [:] + private var percussionControlsUsedForVolume: Set = [] private let heldLock = NSLock() /// Refcount of held drum lights per display note (a display note can be @@ -172,6 +180,7 @@ final class MenuBandController { private let keymapKey = "notepat.keymap" private let percussionLeftKey = KeyboardIconRenderer.percussionLeftDefaultsKey private let percussionRightKey = KeyboardIconRenderer.percussionRightDefaultsKey + private let percussionVolumeKey = "notepat.percussionVolume" private let masterVolumeKey = "notepat.masterVolume" /// Active instrument backend: `"gm"` for the General MIDI bank, or /// `"gb"` for a GarageBand sampler patch. Default is GM. Stored as a @@ -971,6 +980,41 @@ final class MenuBandController { } } + /// Persistent percussion-only output trim. 100% is the kit's historical + /// fixed loudness; fresh installs start at 75% so drums have room to move + /// above or below the melodic/system-volume mix. + var percussionVolume: Float { + get { + if UserDefaults.standard.object(forKey: percussionVolumeKey) == nil { + return 0.75 + } + let raw = UserDefaults.standard.double(forKey: percussionVolumeKey) + return Float(max(0.0, min(1.0, raw))) + } + set { + let clamped = max(0, min(1, newValue)) + UserDefaults.standard.set(Double(clamped), forKey: percussionVolumeKey) + synth.setPercussionVolume(clamped) + onChange?() + } + } + + /// A deliberate tap moves 10 percentage points; macOS key repeat then + /// switches to 1-point steps, making a held arrow feel like a 100-value + /// analog trim without making single taps imperceptible. + static func steppedPercussionVolume(_ current: Float, + direction: Int, + isRepeat: Bool) -> Float { + let step: Float = isRepeat ? 0.01 : 0.10 + let next = current + (direction >= 0 ? step : -step) + return max(0, min(1, next)) + } + + private func adjustPercussionVolume(direction: Int, isRepeat: Bool) { + percussionVolume = Self.steppedPercussionVolume( + percussionVolume, direction: direction, isRepeat: isRepeat) + } + var effectiveMelodicProgram: UInt8 { previewProgram ?? melodicProgram } @@ -1777,6 +1821,7 @@ final class MenuBandController { // volume (matches previous behaviour); explicit lower picks // survive the relaunch. synth.setMasterVolume(masterVolume) + synth.setPercussionVolume(percussionVolume) // Migrate the former radio-as-piano backend into the standalone CDJ // deck. The saved GM voice remains the keyboard instrument. if instrumentBackend == .kpbj { @@ -3086,21 +3131,72 @@ final class MenuBandController { if hasModifier && isDown && !(chordModifier && percussionActive(forKeyCode: keyCode)) { return false } + // Hold either percussion latch key (`[` / `]`) and use Up/Down as a + // percussion-only level trim. An initial press moves 10%; key repeat + // moves 1% at a time for a smooth 0...100 sweep. Unmodified arrows + // remain untouched unless a bracket is physically down. + if keyCode == 126 /* kVK_UpArrow */ || keyCode == 125 /* kVK_DownArrow */ { + heldLock.lock() + let modifierHeld = !percussionControlKeysHeld.isEmpty + let alreadyClaimed = percussionVolumeArrowKeysHeld.contains(keyCode) + if isDown && modifierHeld { + percussionVolumeArrowKeysHeld.insert(keyCode) + percussionControlsUsedForVolume.formUnion(percussionControlKeysHeld) + } + if !isDown && alreadyClaimed { percussionVolumeArrowKeysHeld.remove(keyCode) } + heldLock.unlock() + + if isDown && modifierHeld { + let direction = keyCode == 126 ? 1 : -1 + DispatchQueue.main.async { [weak self] in + self?.adjustPercussionVolume(direction: direction, + isRepeat: isRepeat) + } + return true + } + if !isDown && alreadyClaimed { return true } + } + // Brackets latch the sided percussion split: `[` (33) flips the // LEFT half of the board to drums, `]` (30) the RIGHT half — `]` - // gave up its old ++d note for this. Bare presses only (a modified - // bracket like ⌘-[ passed through above). Consumed in both - // directions; the toggle cue pans to the side that flipped. + // gave up its old ++d note for this. A quick tap still toggles. If + // the key is HELD with a volume arrow, that gesture instead leaves + // (or turns) its half on, so adjusting an already-enabled kit never + // momentarily switches it off. Bare presses only (a modified bracket + // like ⌘-[ passed through above). Consumed in both directions. if keyCode == 33 /* [ */ || keyCode == 30 /* ] */ { + let left = keyCode == 33 + let currentlyEnabled = left ? percussionLeft : percussionRight + var startedEnabled = false + var usedForVolume = false + heldLock.lock() if isDown && !isRepeat { - let left = keyCode == 33 + percussionControlKeysHeld.insert(keyCode) + percussionControlStartedEnabled[keyCode] = currentlyEnabled + } else if !isDown { + percussionControlKeysHeld.remove(keyCode) + startedEnabled = percussionControlStartedEnabled.removeValue( + forKey: keyCode) ?? currentlyEnabled + usedForVolume = percussionControlsUsedForVolume.remove(keyCode) != nil + } + heldLock.unlock() + + // Enabling remains immediate on key-down so the player can hold + // the bracket and start drumming before release. Disabling waits + // for key-up, which is what lets bracket+arrow mean adjustment. + if isDown && !isRepeat && !currentlyEnabled { + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + if left { self.percussionLeft = true } + else { self.percussionRight = true } + self.playPercussionToggleCue(on: true, pan: left ? 32 : 96) + } + } else if !isDown && startedEnabled && !usedForVolume { DispatchQueue.main.async { [weak self] in guard let self = self else { return } - if left { self.togglePercussionLeft() } - else { self.togglePercussionRight() } - self.playPercussionToggleCue( - on: left ? self.percussionLeft : self.percussionRight, - pan: left ? 32 : 96) + if left { self.percussionLeft = false } + else { self.percussionRight = false } + self.playPercussionToggleCue(on: false, pan: left ? 32 : 96) } } return true @@ -3676,6 +3772,10 @@ final class MenuBandController { let drumDisplaySnapshot = heldDrumDisplay heldDrumKeys.removeAll() heldDrumDisplay.removeAll() + percussionControlKeysHeld.removeAll() + percussionVolumeArrowKeysHeld.removeAll() + percussionControlStartedEnabled.removeAll() + percussionControlsUsedForVolume.removeAll() heldLock.unlock() // Silence any held chord-extension voices (panic() below also catches // these, but be explicit so MIDI listeners get clean note-offs). The diff --git a/slab/menuband/Sources/MenuBand/MenuBandPercussion.swift b/slab/menuband/Sources/MenuBand/MenuBandPercussion.swift index 1b11ec655a..3f1d39dd27 100644 --- a/slab/menuband/Sources/MenuBand/MenuBandPercussion.swift +++ b/slab/menuband/Sources/MenuBand/MenuBandPercussion.swift @@ -107,7 +107,20 @@ final class MenuBandPercussion { /// Master headroom so a fistful of simultaneous drums doesn't slam the /// limiter — the kit's raw layer volumes sum well past 1.0 by design. + /// `outputLevel` is the user's independent percussion trim: 1.0 retains + /// the kit's historical loudness, while fresh installs begin at 0.75. private let masterGain: Float = 0.34 + private var outputLevel: Float = 0.75 + + /// Set the independent percussion trim. Snapshot under the same tiny lock + /// used for staged hits so the audio thread sees one stable value per + /// render block. The block-level change avoids zipper noise while arrows + /// auto-repeat through the 0...100 range. + func setOutputLevel(_ value: Float) { + lock.lock() + outputLevel = max(0, min(1, value)) + lock.unlock() + } /// Global pitch multiplier applied to every voice's phase increment /// (and noise LPF cutoff) so the trackpad pitch-bend warps the whole @@ -559,6 +572,7 @@ final class MenuBandPercussion { let pitch = pitchScale // Drain staged voices, releases, and honor a flush request. lock.lock() + let level = outputLevel if flushRequested { active.removeAll(keepingCapacity: true); flushRequested = false } if !pending.isEmpty { for p in pending where active.count < maxVoices { active.append(p) } @@ -592,7 +606,7 @@ final class MenuBandPercussion { if active.isEmpty { return } let dt = 1.0 / sampleRate - let g = Double(masterGain) + let g = Double(masterGain * level) var w = 0 for idx in 0..