diff --git a/reports/2026-08-01-keeps-audit-mainnet.md b/reports/2026-08-01-keeps-audit-mainnet.md new file mode 100644 index 000000000..f4b56bdac --- /dev/null +++ b/reports/2026-08-01-keeps-audit-mainnet.md @@ -0,0 +1,68 @@ +# Keeps Contract Audit (mainnet) + +Generated: 2026-08-01T00:23:19.958Z + +## Snapshot +- Contract: KT1Q1irsjSZ7EfUN4qHzAB2t7xLBPsAWYwBB +- codeHash: 1692834636 +- typeHash: 399679480 +- Admin: tz1Lc2DzTjDPyWFj1iuAVGGZWNjK67Wun2dC +- Treasury: tz1Lc2DzTjDPyWFj1iuAVGGZWNjK67Wun2dC +- Keep fee: 2.500000 XTZ +- Paused: false +- Contract metadata locked: false +- Royalty split: artist 900 bps, platform 100 bps +- Minted tokens (next_token_id): 92 +- Current owners: 32 + +## Alerts +- [warning] Contract metadata is not locked. +- [warning] Recent admin entrypoint call: withdraw_fees by tz1Lc2DzTjDPyWFj1iuAVGGZWNjK67Wun2dC at 2026-06-06T19:51:16Z. +- [warning] Recent admin entrypoint call: withdraw_fees by tz1Lc2DzTjDPyWFj1iuAVGGZWNjK67Wun2dC at 2026-05-25T16:11:34Z. +- [warning] Recent admin entrypoint call: withdraw_fees by tz1Lc2DzTjDPyWFj1iuAVGGZWNjK67Wun2dC at 2026-04-09T18:34:55Z. +- [warning] Recent admin entrypoint call: withdraw_fees by tz1Lc2DzTjDPyWFj1iuAVGGZWNjK67Wun2dC at 2026-04-08T19:08:19Z. +- [warning] Recent admin entrypoint call: withdraw_fees by tz1Lc2DzTjDPyWFj1iuAVGGZWNjK67Wun2dC at 2026-03-30T03:02:46Z. + +## Holder Concentration +- tz1inPpZMzFUv5mkmqDMEC8sYxmEq53vxRhw (f3nt0n): 42 tokens (45.65%) +- tz1X568Wdkb1ZUs8qfVYcsZD31YQ4UV3sdY4 (Absurdeity): 12 tokens (13.04%) +- tz1dFcfEbAb9abXBEXERV4uMv48e4x17MuBb (nbswwit): 4 tokens (4.35%) +- tz1W7eSvs5f1uZ92e8RYjKanhbJT5wgpwrZM: 3 tokens (3.26%) +- tz2VcaiMpgVvJr7gKcaoBdY5i7dvX6nZkCQV: 2 tokens (2.17%) +- tz1LBwyJMRkH4tcG19KwYzAW7fLYbjFmWdWy: 2 tokens (2.17%) +- tz1T53uUPsA9s9Y6hq8SoFg4rzLx2GpUkZqt: 2 tokens (2.17%) +- tz1U49poeoYHDNtx7KhkHmp8hqXt1j9fBWwr (0009): 1 tokens (1.09%) +- tz1fhMwbUtYBdJtGEZDSKPVaGqu1LCADSGNJ: 1 tokens (1.09%) +- tz1YKS3pgkBDyoFfSjDCBiC9KYtkfwWfaBGZ: 1 tokens (1.09%) + +## Recent Contract Operations +- 2026-07-23T03:39:31Z · transfer · KT1SwbTqhSKF6Pdokiu1K4Fpi17ahPPzmt1X · opXEWwpukTUNTEhZus3Y5kWLCJdxftNaCT7rQwoAPxua8YYV6Ef +- 2026-06-18T22:44:43Z · transfer · KT1SwbTqhSKF6Pdokiu1K4Fpi17ahPPzmt1X · oo92nkhSwimxyM7p4wVsrdmBWohDg7pCr3SqW3qTsBRmMxMrgF9 +- 2026-06-18T08:06:52Z · transfer · KT1SwbTqhSKF6Pdokiu1K4Fpi17ahPPzmt1X · op4GAoPyPXABLMtTFPbnooCyL79kYEY54CquesQU7gyDzV9iTBT +- 2026-06-18T08:04:10Z · transfer · KT1SwbTqhSKF6Pdokiu1K4Fpi17ahPPzmt1X · oocDTSfv6ff8sDeC3ogpuiYuQW7yohiLiSJt6aDGRsEqMz4wjoF +- 2026-06-17T06:40:52Z · transfer · KT1SwbTqhSKF6Pdokiu1K4Fpi17ahPPzmt1X · ooPpe5FC1kQyGL3mwwYL7XVc6JB6g3oYThHmzYQuyWVw9rYBFJa +- 2026-06-17T03:40:07Z · update_operators · tz1gkf8EexComFBJvjtT1zdsisdah791KwBE · oocnbUz9Lr87deecq6aHHokxukmN9FtuPD64VYMFPVdNtMf2EvG +- 2026-06-17T03:39:43Z · update_operators · tz1gkf8EexComFBJvjtT1zdsisdah791KwBE · ooHbTXpg5K53c6mCQ9bSAT3V2dfN1KYPZjBuY5pNgthpaiYcP19 +- 2026-06-17T03:39:13Z · update_operators · tz1gkf8EexComFBJvjtT1zdsisdah791KwBE · oongZTu49e6f5HDh1Lzt58QQhhsgF2AYXrdo5TrgaqfQS2z3PRr +- 2026-06-17T03:12:43Z · keep · tz1gkf8EexComFBJvjtT1zdsisdah791KwBE · oo7m1NmcMQHpaDgyig4vBB2Nk1AYwzsTCZouh4jTeH4oTFgE4ja +- 2026-06-17T01:45:52Z · keep · tz1gkf8EexComFBJvjtT1zdsisdah791KwBE · oojqFnUfvmtMKRJmtXkf3S1RxNr7gp6LA1HMrZkKDYy5Y2bwoF3 +- 2026-06-17T01:35:28Z · keep · tz1gkf8EexComFBJvjtT1zdsisdah791KwBE · ooLnUupWTfpW3sTgMXTKbLgb6qgwA7kM1Uq8CQXEJD7TP1V3kEc +- 2026-06-14T12:07:28Z · transfer · KT1SwbTqhSKF6Pdokiu1K4Fpi17ahPPzmt1X · ooetchNQcCRTw57PzFkENgmczdT1CYMMNFL7DpcKCEf8eQq8Pcx + +## Recent Admin Operations +- 2026-06-06T19:51:16Z · withdraw_fees · tz1Lc2DzTjDPyWFj1iuAVGGZWNjK67Wun2dC · ooHQ3ZYG8DRw8WzHrTwiKApQcBv7Z83BvSBaJWxU2LVUFFSn5QS +- 2026-05-25T16:11:34Z · withdraw_fees · tz1Lc2DzTjDPyWFj1iuAVGGZWNjK67Wun2dC · ootxHERyy6kigfPsNAeNXETyHHmnutjReeMB4ULmHhTtVDaUdY6 +- 2026-04-09T18:34:55Z · withdraw_fees · tz1Lc2DzTjDPyWFj1iuAVGGZWNjK67Wun2dC · ooeVZiRvMyfVj4QVYh4m2hhTxXBuuikDFvFV9HJk6Rt4F2tP7uY +- 2026-04-08T19:08:19Z · withdraw_fees · tz1Lc2DzTjDPyWFj1iuAVGGZWNjK67Wun2dC · onv5SS77DwnTg8faoaQHkiTnF9NmxbJaA58NCQbq7o6pspty1mR +- 2026-03-30T03:02:46Z · withdraw_fees · tz1Lc2DzTjDPyWFj1iuAVGGZWNjK67Wun2dC · opY4cX1GjwxeiL5PL2Qm9KikGHjeDkEGU7g7xkkG2jmggae9iD6 +- 2026-03-30T02:44:31Z · withdraw_fees · tz1Lc2DzTjDPyWFj1iuAVGGZWNjK67Wun2dC · opMaBeYCVkaX6ftH2iQxd7P8hrsiYS6TG95Qa1QGaaJFJeYfrvp +- 2026-03-24T20:02:01Z · withdraw_fees · tz1Lc2DzTjDPyWFj1iuAVGGZWNjK67Wun2dC · oneWxD9uiHQd2DvPhP3nWXpwrQtGoZqBhN8P22MFWQTAy4UeTCL +- 2026-03-23T17:42:04Z · withdraw_fees · tz1Lc2DzTjDPyWFj1iuAVGGZWNjK67Wun2dC · opSeQzNzdEHZVdVEZtAx3XRhg5Zv6u51ghTcm529rCAmsmdvZUr +- 2026-03-23T05:18:49Z · withdraw_fees · tz1Lc2DzTjDPyWFj1iuAVGGZWNjK67Wun2dC · ooYgVAH6XrevME5sHTHg3Z5CckCeRYL8bvgt4Rvq5tPaRUDJmT2 +- 2026-03-23T03:28:34Z · withdraw_fees · tz1Lc2DzTjDPyWFj1iuAVGGZWNjK67Wun2dC · ooT5ootQf83TAEXeUP6ARhVeZuJeKerVhMoFhQbN4hYqFWBuv4S +- 2026-03-22T02:35:58Z · withdraw_fees · tz1Lc2DzTjDPyWFj1iuAVGGZWNjK67Wun2dC · oo6qcbfcCfTuuAGDMCvXoZ6hv86xbun6cdSNhWy6NPifhh1WnDY +- 2026-03-20T15:53:49Z · withdraw_fees · tz1Lc2DzTjDPyWFj1iuAVGGZWNjK67Wun2dC · oo9tV3hKPoqGG77WpqicDZV3iChKnawcUWrsXXuzchCyJ3WTkL1 + +## Notes +- This report is chain-derived (TzKT). +- Use with the v11 moat checks in keep-mint for runtime enforcement. diff --git a/slab/menuband/Sources/MenuBand/AppDelegate.swift b/slab/menuband/Sources/MenuBand/AppDelegate.swift index 856ceb58d..acdafec04 100644 --- a/slab/menuband/Sources/MenuBand/AppDelegate.swift +++ b/slab/menuband/Sources/MenuBand/AppDelegate.swift @@ -83,12 +83,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private lazy var squawk = MenuBandSquawk() private var popoverPanel: MenuBandPopoverPanel? private var popoverVC: MenuBandPopoverViewController? - /// Indirect-touch sensor embedded in the popover so trackpad - /// pitch-bend still engages while the popover is the key window - /// (the off-screen capture sensor only sees fingers when IT is key, - /// which it isn't once the popover takes focus). First-responder of - /// the popover panel; mouse passes straight through (hitTest → nil). - private var popoverTouchSensor: TouchSensorView? // [v1 cutoff] KidLisp TV panel + its `$` piece chooser cache + amp // provider state removed. The shared KidLispState + the About-opened // aesthetic.computer web window survive on their own. @@ -390,6 +384,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate { /// distances inherit trackpad quantization and callback jitter, which made /// a steady drag sound as though it were repeatedly accelerating. private var trackpadScratchSpeed: Double? + /// Scratch must be an uninterrupted drag, never a by-product of fingers + /// alternately tapping the membrane. Begins/lifts reset this candidate; + /// only sustained travel across several hardware frames arms friction. + private var trackpadScratchCandidateTravel: CGFloat = 0 + private var trackpadScratchCandidateFrames = 0 + private var trackpadScratchArmed = false private var trackpadSurfaceEnergy = TrackpadSurfaceEnergy() private var trackpadEnergyTimer: Timer? private var trackpadOverlayLastDraw: Double = 0 @@ -450,7 +450,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate { return true // consume — don't let ⌘-Tab / focus traversal fire } #else - private let trackpadFxAvailable = false + // Public NSTouch frames make the focused percussion surface available in + // the sandbox. It remains deliberately focus-local; no global HID tap. + private let trackpadFxAvailable = true #endif /// Tracks the last lit-note count we observed in `onLitChanged` /// so we can detect the all-notes-released edge and trigger @@ -1263,16 +1265,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // been retired (use the on-screen ArrowKeysIndicator // chevrons in the floating panel for mouse-driven // stepping). - // Keep the popover's touch sensor as first responder while - // the user plays — a stray mouse click on a popover control - // could otherwise move first-responder and stop indirect - // touches from routing to the sensor mid-phrase, silently - // breaking trackpad pitch-bend. Cheap to re-assert; only - // acts when it isn't already first responder. - if isDown, let sensor = self.popoverTouchSensor, - sensor.window?.firstResponder !== sensor { - sensor.window?.makeFirstResponder(sensor) - } let consumed = self.menuBand.handleLocalKey( keyCode: keyCode, isDown: isDown, isRepeat: isRepeat, flags: flags ) @@ -1299,6 +1291,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate { localCapture.onTrackpadTouchActiveChanged = { [weak self] active in self?.setTrackpadTouchActive(active) } + #if MAC_APP_STORE + localCapture.onTrackpadFrame = { [weak self] contacts, timestamp, callbackTime in + self?.handleTrackpadFrame(contacts, timestamp: timestamp, + callbackTime: callbackTime) + } + #endif // Bluetooth game controller. The Menu button toggles the popover // (showPopover already closes it when shown). The gamepad config UI @@ -3512,8 +3510,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // resting-finger flag so a held bend doesn't strand once the // sensor that was reporting it is gone (the capture-panel sensor // takes over again if the user keeps playing with notes held). - popoverTouchSensor?.removeFromSuperview() - popoverTouchSensor = nil if !menuBand.keyboardNotesHeld { setTrackpadTouchActive(false) } // [v1 cutoff] KidLisp TV panel removed — no child panel to tear down. if let panel = panelToFade { @@ -3884,17 +3880,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // person is drumming (not a silent engine hit). let litNote = AppDelegate.drumLightNote(for: sym) let onEpoch = downbeatEpoch + (onAt - leadIn) + let offEpoch = onEpoch + max(0.025, dur * 0.9) DispatchQueue.main.asyncAfter(deadline: at(onEpoch)) { [weak self] in guard let self = self, self.playGeneration == gen else { return } - _ = self.menuBand.percussionNoteOn(drum, velocity: vel, - pan: 64, accent: false) + let group = self.menuBand.percussionNoteOn( + drum, velocity: vel, pan: 64, accent: false + ) self.menuBand.drumLitOn(litNote) if syncEpoch != nil { self.fleetHits.append((onEpoch, Date().timeIntervalSince1970)) } - } - // Drums are one-shots — hold the pad light briefly. - let offEpoch = onEpoch + min(dur, 0.12) - DispatchQueue.main.asyncAfter(deadline: at(offEpoch)) { [weak self] in - self?.menuBand.drumLitOff(litNote) + DispatchQueue.main.asyncAfter(deadline: at(offEpoch)) { [weak self] in + self?.menuBand.percussionNoteOff(group) + self?.menuBand.drumLitOff(litNote) + } } } else if let midi = UInt8(sym) { // Melodic note via the real tap path (lights keys + waveform). @@ -4370,24 +4367,27 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // first-responder; once it moved to the floating panel // nothing was capturing arrows for the controller. if !localCapture.isArmed { localCapture.arm() } + #if MAC_APP_STORE + // The sandbox has no global focus gesture. Opening the focused + // popover is the explicit user action that arms its public + // NSTouch percussion surface; closing/unfocusing tears it down. + activateDefaultTrackpadDrum() + #endif DispatchQueue.main.async { [weak panel] in panel?.makeKey() } - // Embed a trackpad-touch sensor in the popover (the now-key - // window) so resting fingers are detected here and the - // trackpad pitch-bend gesture engages while the popover is - // open. Made first responder so indirect touches route to it; - // it never steals the mouse (hitTest → nil), so the keys, - // buttons, and chooser grid underneath stay fully clickable. - let sensor = TouchSensorView(frame: panel.chrome.bounds) - sensor.autoresizingMask = [.width, .height] - sensor.onActiveChanged = { [weak self] active in + // Public NSTouch delivery follows the hit-tested view's ancestry, + // so capture on the popover chrome rather than an invisible sibling. + panel.chrome.onTrackpadActiveChanged = { [weak self] active in self?.setTrackpadTouchActive(active) } - panel.chrome.addSubview(sensor) - panel.makeFirstResponder(sensor) - popoverTouchSensor = sensor + #if MAC_APP_STORE + panel.chrome.onTrackpadFrame = { [weak self] contacts, timestamp, callbackTime in + self?.handleTrackpadFrame(contacts, timestamp: timestamp, + callbackTime: callbackTime) + } + #endif // Click-away monitor: clicks on OTHER apps close the popover. // In-app clicks (status item button, the popover itself) don't // fire global monitors and therefore don't dismiss — that's how @@ -4526,6 +4526,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate { trackpadSkinTouches = touches trackpadSkinFrameTimestamp = timestamp trackpadScratchSpeed = nil + trackpadScratchCandidateTravel = 0 + trackpadScratchCandidateFrames = 0 + trackpadScratchArmed = false menuBand.stopTrackpadDrumSkinScratch() } else if continuousSurface, pitchBendCursorPushed { updateTrackpadSurface(touches, timestamp: timestamp, @@ -4660,14 +4663,38 @@ final class AppDelegate: NSObject, NSApplicationDelegate { now: now ) } + let contactChanged = !strikes.isEmpty || !lifts.isEmpty + || touches.count != trackpadSkinTouches.count + if contactChanged { + trackpadScratchSpeed = nil + trackpadScratchCandidateTravel = 0 + trackpadScratchCandidateFrames = 0 + trackpadScratchArmed = false + menuBand.stopTrackpadDrumSkinScratch() + } if touches.isEmpty { trackpadScratchSpeed = nil + trackpadScratchCandidateTravel = 0 + trackpadScratchCandidateFrames = 0 + trackpadScratchArmed = false menuBand.stopTrackpadDrumSkinScratch() - } else if !trackpadSkinTouches.isEmpty, trackpadSkinFrameTimestamp > 0 { + } else if !contactChanged, !trackpadSkinTouches.isEmpty, + trackpadSkinFrameTimestamp > 0 { let dt = max(1.0 / 240.0, min(0.050, timestamp - trackpadSkinFrameTimestamp)) if let scratch = TrackpadDrumSkinPad.dominantScratch( previous: trackpadSkinTouches, current: touches ) { + if !trackpadScratchArmed { + trackpadScratchCandidateFrames += 1 + trackpadScratchCandidateTravel += scratch.movement + trackpadScratchArmed = trackpadScratchCandidateFrames >= 3 + && trackpadScratchCandidateTravel >= 0.018 + } + guard trackpadScratchArmed else { + trackpadSkinTouches = touches + trackpadSkinFrameTimestamp = timestamp + return + } let measuredSpeed = Double(scratch.movement) / dt // A short time-constant removes frame quantization without // adding an accelerating ramp at gesture onset: the first @@ -4877,9 +4904,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate { /// focus is summoned. The cursor lock is the boundary of the global drum: /// Escape/focus loss restores the ordinary pointer and ends percussion. private func activateDefaultTrackpadDrum() { -#if !MAC_APP_STORE guard trackpadFxAvailable else { return } + #if !MAC_APP_STORE setTrackpadFighterSuppressed(true) + #endif trackpadPadMode = .skin trackpadSurfaceEnergy.reset(at: CACurrentMediaTime()) trackpadScratchSpeed = nil @@ -4898,24 +4926,33 @@ final class AppDelegate: NSObject, NSApplicationDelegate { menuBand.setSpace(amount: 0) menuBand.setEcho(amount: 0) + #if !MAC_APP_STORE if !pitchBendCursorLocked { CGAssociateMouseAndMouseCursorPosition(0) pitchBendCursorLocked = true pitchBendLockScreenPoint = NSEvent.mouseLocation } + #endif pitchBendModeLatched = true if !pitchBendCursorPushed { + #if !MAC_APP_STORE PitchBendCursor.neutral.push() - pitchBendCursorPushed = true hideSystemCursorIfNeeded() + #endif + pitchBendCursorPushed = true } + #if !MAC_APP_STORE startTrackpadPercussionLocalClickShield() let globalClickShield = trackpadPercussionGestureTap.start() + #endif // Quiet focus: the global drum is already live, but its chart appears // only after the user actually touches the trackpad. pitchBendOverlay?.dismiss() + #if !MAC_APP_STORE debugLog("trackpad pad mode = skin (focus default) clickShieldGlobal=\(globalClickShield)") -#endif + #else + debugLog("trackpad pad mode = skin (focused NSTouch App Store path)") + #endif } /// Cycle an open trackpad through physical skin, synthetic surface, FX, diff --git a/slab/menuband/Sources/MenuBand/LocalKeyCapture.swift b/slab/menuband/Sources/MenuBand/LocalKeyCapture.swift index 96008462e..fca087119 100644 --- a/slab/menuband/Sources/MenuBand/LocalKeyCapture.swift +++ b/slab/menuband/Sources/MenuBand/LocalKeyCapture.swift @@ -1,4 +1,5 @@ import AppKit +import QuartzCore /// Sandbox-friendly key capture: an invisible 1×1 NSPanel that becomes the /// key window after the user clicks the menubar piano. A local NSEvent @@ -30,6 +31,9 @@ final class LocalKeyCapture { /// long as a finger is down, instead of springing back on a /// silence timeout or a note release. var onTrackpadTouchActiveChanged: ((Bool) -> Void)? + /// Public, sandbox-safe multi-contact frames from NSTouch. Available only + /// while Menu Band owns the focused window/responder chain. + var onTrackpadFrame: (([TrackpadContact], Double, Double) -> Void)? var cancelShortcut: MenuBandShortcut? private var panel: NSPanel? @@ -141,6 +145,9 @@ final class LocalKeyCapture { sensor.onActiveChanged = { [weak self] active in self?.onTrackpadTouchActiveChanged?(active) } + sensor.onFrame = { [weak self] contacts, timestamp, callbackTime in + self?.onTrackpadFrame?(contacts, timestamp, callbackTime) + } p.contentView = sensor // Rob ⌘/⌥/⌃+letter combos from the system. The local keyDown monitor // (above) doesn't reliably see modifier key-equivalents, and the @@ -176,17 +183,23 @@ final class TouchSensorView: NSView { /// bottom-left), nil when no finger touches — the clean signal for a /// flat-mapped XY pad (no pointer acceleration). var onTouchXY: ((CGPoint?) -> Void)? + /// Complete public NSTouch contact frame, using stable identities so the + /// shared transition engine can distinguish held, new, and lifted fingers. + var onFrame: (([TrackpadContact], Double, Double) -> Void)? private var lastActive = false private var loggedCount = 0 + private var loggedFrameCount = 0 override init(frame frameRect: NSRect) { super.init(frame: frameRect) + acceptsTouchEvents = true allowedTouchTypes = [.indirect] wantsRestingTouches = true } required init?(coder: NSCoder) { super.init(coder: coder) + acceptsTouchEvents = true allowedTouchTypes = [.indirect] wantsRestingTouches = true } @@ -207,6 +220,25 @@ final class TouchSensorView: NSView { private func recompute(_ event: NSEvent) { let touches = event.touches(matching: .touching, in: self) + let beganIdentities = Set(event.touches(matching: .began, in: self).map { + ObjectIdentifier($0.identity as AnyObject) + }) + let contacts = touches.filter { $0.type == .indirect }.map { touch in + let identity = ObjectIdentifier(touch.identity as AnyObject) + return TrackpadContact( + identifier: Int32(truncatingIfNeeded: identity.hashValue), + point: touch.normalizedPosition, + state: beganIdentities.contains(identity) ? 3 : 4 + ) + } + if loggedFrameCount < 24 { + loggedFrameCount += 1 + NSLog("MenuBand NSTouch frame: event=%@ contacts=%d firstResponder=%@ key=%@", + String(describing: event.type), contacts.count, + (window?.firstResponder === self) ? "yes" : "no", + window?.isKeyWindow == true ? "yes" : "no") + } + onFrame?(contacts, event.timestamp, CACurrentMediaTime()) let active = !touches.isEmpty // [prototype] Report + log the primary finger's absolute position so we // can confirm whether indirect-touch delivery actually reaches this view diff --git a/slab/menuband/Sources/MenuBand/MenuBandController.swift b/slab/menuband/Sources/MenuBand/MenuBandController.swift index 44cbe39dc..31c19a6ab 100644 --- a/slab/menuband/Sources/MenuBand/MenuBandController.swift +++ b/slab/menuband/Sources/MenuBand/MenuBandController.swift @@ -1030,14 +1030,14 @@ final class MenuBandController { return UInt8(max(0, min(127, raw))) } - /// Persistent melodic baseline, 0.0…1.0. Default 0.82; percussion-driven + /// Persistent joined-output level, 0.0…1.0. Default 1.0; percussion-driven /// sidechain automation ducks beneath it dynamically during drum attacks. - /// Lives on the pre-limiter sum bus inside the synth so every backend + /// Lives on the joined post-FX bus inside the synth so every backend /// scales together — drag the popover slider and the whole mix moves. var masterVolume: Float { get { if UserDefaults.standard.object(forKey: masterVolumeKey) == nil { - return 0.82 + return 1.0 } let raw = UserDefaults.standard.double(forKey: masterVolumeKey) return Float(max(0.0, min(1.0, raw))) @@ -1050,12 +1050,12 @@ final class MenuBandController { } /// Persistent percussion-only output trim. 100% is the kit's historical - /// fixed loudness; fresh installs start at 58% so drum transients lead the + /// fixed loudness; fresh installs start at 80% so drum transients lead the /// melodic voice without masking its body. var percussionVolume: Float { get { if UserDefaults.standard.object(forKey: percussionVolumeKey) == nil { - return 0.58 + return 0.80 } let raw = UserDefaults.standard.double(forKey: percussionVolumeKey) return Float(max(0.0, min(1.0, raw))) diff --git a/slab/menuband/Sources/MenuBand/MenuBandPercussion.swift b/slab/menuband/Sources/MenuBand/MenuBandPercussion.swift index 8334dc7ae..83615fa00 100644 --- a/slab/menuband/Sources/MenuBand/MenuBandPercussion.swift +++ b/slab/menuband/Sources/MenuBand/MenuBandPercussion.swift @@ -110,9 +110,9 @@ 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.58. + /// the kit's historical loudness, while fresh installs begin at 0.80. private let masterGain: Float = 0.34 - private var outputLevel: Float = 0.58 + private var outputLevel: Float = 0.80 /// Set the independent percussion trim. Snapshot under the same tiny lock /// used for staged hits so the audio thread sees one stable value per diff --git a/slab/menuband/Sources/MenuBand/MenuBandPopoverPanel.swift b/slab/menuband/Sources/MenuBand/MenuBandPopoverPanel.swift index 43ecba2f2..a5fd00c8d 100644 --- a/slab/menuband/Sources/MenuBand/MenuBandPopoverPanel.swift +++ b/slab/menuband/Sources/MenuBand/MenuBandPopoverPanel.swift @@ -16,6 +16,7 @@ // arrow into the body. import AppKit +import QuartzCore final class MenuBandPopoverPanel: NSPanel { static let arrowHeight: CGFloat = 11 @@ -122,6 +123,10 @@ final class MenuBandPopoverPanel: NSPanel { } final class MenuBandPopoverChrome: NSView { + var onTrackpadActiveChanged: ((Bool) -> Void)? + var onTrackpadFrame: (([TrackpadContact], Double, Double) -> Void)? + private var trackpadActive = false + private var loggedTrackpadFrames = 0 /// The single backdrop surface for the whole popover (body + arrow). /// On macOS 26 this is a real liquid-glass `NSGlassEffectView`; on /// older systems it falls back to the `.popover` `NSVisualEffectView`. @@ -158,6 +163,8 @@ final class MenuBandPopoverChrome: NSView { self.legacyVisualEffect = ve } super.init(frame: .zero) + allowedTouchTypes = [.indirect] + wantsRestingTouches = true wantsLayer = true layer?.masksToBounds = false @@ -187,6 +194,42 @@ final class MenuBandPopoverChrome: NSView { content.layer?.mask = contentMaskLayer } + // AppKit targets indirect-touch gestures using the ordinary mouse hit-test + // ancestry. Capturing on the chrome (an ancestor of every popover control) + // keeps buttons clickable and still receives NSTouch without private APIs. + override func touchesBegan(with event: NSEvent) { reportTrackpad(event) } + override func touchesMoved(with event: NSEvent) { reportTrackpad(event) } + override func touchesEnded(with event: NSEvent) { reportTrackpad(event) } + override func touchesCancelled(with event: NSEvent) { reportTrackpad(event) } + + private func reportTrackpad(_ event: NSEvent) { + let touching = event.touches(matching: .touching, in: self) + .filter { $0.type == .indirect } + let began = Set(event.touches(matching: .began, in: self).map { + ObjectIdentifier($0.identity as AnyObject) + }) + let contacts = touching.map { touch in + let identity = ObjectIdentifier(touch.identity as AnyObject) + return TrackpadContact( + identifier: Int32(truncatingIfNeeded: identity.hashValue), + point: touch.normalizedPosition, + state: began.contains(identity) ? 3 : 4 + ) + } + if loggedTrackpadFrames < 24 { + loggedTrackpadFrames += 1 + NSLog("MenuBand popover NSTouch: event=%@ contacts=%d key=%@", + String(describing: event.type), contacts.count, + window?.isKeyWindow == true ? "yes" : "no") + } + onTrackpadFrame?(contacts, event.timestamp, CACurrentMediaTime()) + let active = !contacts.isEmpty + if active != trackpadActive { + trackpadActive = active + onTrackpadActiveChanged?(active) + } + } + /// Force the legacy NSVisualEffectView to re-tint against the /// current effective appearance. AppDelegate calls this from /// `systemAppearanceChanged()` because NSVisualEffectView can latch diff --git a/slab/menuband/Sources/MenuBand/MenuBandSynth.swift b/slab/menuband/Sources/MenuBand/MenuBandSynth.swift index 971ae0d2f..0e6bd5876 100644 --- a/slab/menuband/Sources/MenuBand/MenuBandSynth.swift +++ b/slab/menuband/Sources/MenuBand/MenuBandSynth.swift @@ -75,10 +75,14 @@ final class MenuBandSynth { /// directly and a chord across melodic + drums + midiSynth could exceed /// 0 dBFS at the output (audible clipping/crackle). private let preLimiterMixer = AVAudioMixerNode() - /// Sums the fx-processed melodic bus with the DRY percussion bus right - /// before the compressor. Percussion routes here instead of through + /// Realtime gain stage after melodic compression. Percussion-triggered + /// automation moves this node without disturbing the proximity EQ's own + /// user-controlled makeup gain. + private let melodicDuckMixer = AVAudioMixerNode() + /// Sums the compressed/ducked melodic bus with the DRY percussion bus + /// immediately before the final limiter. Percussion routes here instead of through /// echo/reverb/proximity, so the trackpad fx (and pitch-bend) never - /// touch the drums — they still get the master compressor + limiter and + /// touch the drums — they still get master volume + final limiting and /// are captured by the tape (which taps `mainMixerNode`, downstream). private let postFxMixer = AVAudioMixerNode() /// Apple PeakLimiter on the master path. Catches transient peaks from @@ -286,8 +290,9 @@ final class MenuBandSynth { /// BEFORE the limiter means lowering the slider also pulls the /// peak-limit threshold down proportionally — the slider is the /// user's "max volume" knob in a literal sense. - /// Baseline melodic trim; percussion sidechain automation moves beneath it. - private var masterVolume: Float = 0.82 + /// Baseline joined-output trim; percussion sidechain automation moves the + /// independent melodic bus beneath it. + private var masterVolume: Float = 1.0 private var melodicDuckGain: Float = 1 private var melodicDuckTarget: Float = 1 private var melodicDuckHoldUntil: CFTimeInterval = 0 @@ -398,6 +403,7 @@ final class MenuBandSynth { engine.attach(echo) engine.attach(spaceReverb) engine.attach(proximityEQ) + engine.attach(melodicDuckMixer) engine.attach(postFxMixer) engine.attach(compressor) engine.attach(limiter) @@ -490,10 +496,10 @@ final class MenuBandSynth { DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in self?.measuredOutputLatency() } - // Apply the persisted master volume now that preLimiterMixer is + // Apply the persisted master volume now that the joined output bus is // attached + connected (setting outputVolume before attach is a // silent no-op on AVAudioMixerNode). - preLimiterMixer.outputVolume = masterVolume + postFxMixer.outputVolume = masterVolume // Recover from audio-device changes (aux cable, headphones, // AirPods, sample-rate switch). See `configChangeObserver`. @@ -780,27 +786,29 @@ final class MenuBandSynth { private func connectLimiterIfNeeded() { guard !limiterConnected else { return } // preLimiterMixer → echo → spaceReverb → proximityEQ → compressor → - // limiter → main. Echo is first so the reverb washes its repeats + // melodicDuckMixer → postFxMixer (+ dry percussion) → limiter → main. + // Echo is first so the reverb washes its repeats // (not vice-versa); proximityEQ (left-axis "closer/tinier") sits // last in the fx group so it shrinks the whole wet+dry blend. All - // sit pre-compressor so their tails are leveled + peak-controlled - // and pre-master so the volume slider scales them with everything. - // The compressor does the loudness normalization (glue + makeup gain); + // sit pre-compressor so melodic tails are leveled before the selective + // duck. Dry percussion joins only after that automation, so compressor + // makeup cannot restore the pocket. The joined mixer owns master gain. // the PeakLimiter remains the final brick wall at 0 dBFS. engine.connect(preLimiterMixer, to: echo, format: nil) engine.connect(echo, to: spaceReverb, format: nil) engine.connect(spaceReverb, to: proximityEQ, format: nil) - engine.connect(proximityEQ, to: postFxMixer, format: nil) - engine.connect(postFxMixer, to: compressor, format: nil) - engine.connect(compressor, to: limiter, format: nil) + engine.connect(proximityEQ, to: compressor, format: nil) + engine.connect(compressor, to: melodicDuckMixer, format: nil) + engine.connect(melodicDuckMixer, to: postFxMixer, format: nil) + engine.connect(postFxMixer, to: limiter, format: nil) engine.connect(limiter, to: engine.mainMixerNode, format: nil) // "Glued master" compressor: pull quiet notes up and tame loud // chords toward a common level, then makeup-gain the whole bus so - // ordinary playing sits near 0 dBFS. Soft knee (HeadRoom) keeps it - // musical rather than slammed; +9 dB makeup is what actually makes - // it "as loud as it can be" — the downstream limiter absorbs any - // overshoot so the makeup can be generous without clipping. + // ordinary playing stays controlled. Soft knee (HeadRoom) keeps it + // musical rather than slammed. Makeup remains neutral because this + // compressor now belongs only to the melodic bus; boosting it here + // would overwhelm the dry percussion that joins downstream. // Expansion left at defaults (ExpansionThreshold ≈ -100 dB) so the // low-end expander never acts as a noise gate on soft tails. let cAU = compressor.audioUnit @@ -818,10 +826,10 @@ final class MenuBandSynth { // audible pumping on sustained pads/chords. AudioUnitSetParameter(cAU, kDynamicsProcessorParam_ReleaseTime, kAudioUnitScope_Global, 0, 0.18, 0) - // Makeup gain — the "normalize to max" stage. (kDynamicsProcessorParam - // _MasterGain is spelled _OverallGain in the Swift-imported header.) + // Neutral makeup. (kDynamicsProcessorParam_MasterGain is spelled + // _OverallGain in the Swift-imported header.) AudioUnitSetParameter(cAU, kDynamicsProcessorParam_OverallGain, - kAudioUnitScope_Global, 0, 9.0, 0) + kAudioUnitScope_Global, 0, 0.0, 0) let au = limiter.audioUnit // Fast attack catches chord/transient peaks; medium release avoids @@ -1608,7 +1616,7 @@ final class MenuBandSynth { // MARK: - Public API - /// Master output gain, 0.0…1.0. Stored on the pre-limiter sum bus + /// Master output gain, 0.0…1.0. Stored on the joined post-FX bus /// so every backend scales together. Idempotent — safe to call /// before or after `start()`. Clamped to [0, 1]. func setMasterVolume(_ value: Float) { @@ -1617,7 +1625,7 @@ final class MenuBandSynth { // outputVolume is realtime-safe — AVAudioEngine ramps it to // the new target rather than snapping, so dragging the slider // doesn't click. - preLimiterMixer.outputVolume = clamped + postFxMixer.outputVolume = clamped } /// Fast musical sidechain on the melodic bus. Kick-like events make the @@ -1653,11 +1661,11 @@ final class MenuBandSynth { // Near-instant attack, slower transparent recovery. let rate: Float = target < self.melodicDuckGain ? 0.48 : 0.055 self.melodicDuckGain += (target - self.melodicDuckGain) * rate - self.proximityEQ.globalGain = 20 * log10(max(0.001, self.melodicDuckGain)) + self.melodicDuckMixer.outputVolume = self.melodicDuckGain if !held, abs(self.melodicDuckGain - 1) < 0.001 { self.melodicDuckGain = 1 self.melodicDuckTarget = 1 - self.proximityEQ.globalGain = 0 + self.melodicDuckMixer.outputVolume = 1 timer.invalidate() self.melodicDuckTimer = nil } diff --git a/slab/menuband/fastlane/metadata/en-US/release_notes.txt b/slab/menuband/fastlane/metadata/en-US/release_notes.txt index 985564b55..4594a2757 100644 --- a/slab/menuband/fastlane/metadata/en-US/release_notes.txt +++ b/slab/menuband/fastlane/metadata/en-US/release_notes.txt @@ -1 +1 @@ -Plug in a hardware MIDI keyboard and play Menu Band's instrument palette directly. The new CDJ Radio deck tunes KPBJ, R8DIO, NTS 1, and NTS 2 beside the piano, with a scratchable platter and an explicit sample-to-piano handoff. Recording cues are smoother, physical Command shortcuts work reliably, and idle audio behaves better between sessions. +Your trackpad is now a percussion instrument. Focus Menu Band, tap the multi-touch drum skin, and move from deep center hits toward brighter edges; Tab cycles physical, synthetic, FX, and kit surfaces. Melodic parts automatically make room for each strike, score percussion follows its written note lengths, and the whole mix is louder and better balanced. diff --git a/slab/menuband/project.yml b/slab/menuband/project.yml index 431be4a6a..8e38ecb17 100644 --- a/slab/menuband/project.yml +++ b/slab/menuband/project.yml @@ -41,6 +41,10 @@ settings: MARKETING_VERSION: "1.6.6" CURRENT_PROJECT_VERSION: "166" +packages: + ACMacAudio: + path: ../macos-audio + targets: MenuBand: type: application @@ -101,6 +105,8 @@ targets: - path: Sources/CGMSynth/gm_synth.c dependencies: + - package: ACMacAudio + product: ACMacAudio # Package.swift links IOKit explicitly (used without a Swift import); # everything else (AppKit, AVFoundation, CoreMIDI, CoreAudio, Metal, # MetalKit, WebKit, Carbon) auto-links from its `import`. diff --git a/slab/menuband/scores/mix-test-whistle.mbscore b/slab/menuband/scores/mix-test-whistle.mbscore index c5e23fb83..c8238595e 100644 --- a/slab/menuband/scores/mix-test-whistle.mbscore +++ b/slab/menuband/scores/mix-test-whistle.mbscore @@ -1,23 +1,23 @@ { - "title": "Whistle Sidechain Mix Test", + "title": "Whistle Sidechain Demo", "composer": "Menu Band lab", "machines": 1, "bpm": 120, - "lead": 1.0, + "lead": 1, "record": true, - "description": "Program 79 whistle over dense kick, snare, clap, and hat percussion for automix QA.", + "description": "A 32-second whistle-and-percussion form: entrance, build, breakdown, and bright final return.", "voices": [ { - "name": "whistle and dense kit", + "name": "four-movement whistle and kit", "program": 79, "velocity": 104, - "notes": "k:0.5,k:0.5,k:0.5,k:0.5,k:0.5,k:0.5,k:0.5,k:0.5,k:0.5,k:0.5,k:0.5,k:0.5,k:0.5,k:0.5,k:0.5,k:0.5,k:0.5,k:0.5,k:0.5,k:0.5,k:0.5,k:0.5,k:0.5,k:0.5,k:0.5,k:0.5,k:0.5,k:0.5,k:0.5,k:0.5,k:0.5,k:0.5", - "notes2": "r:1,s:1,r:1,c:1,r:1,s:1,r:1,c:1,r:1,s:1,r:1,c:1,r:1,s:1,r:1,c:1", - "velocity2": 96, - "notes3": "h:0.25,h:0.25,h:0.25,h:0.25,h:0.25,h:0.25,h:0.25,h:0.25,h:0.25,h:0.25,h:0.25,h:0.25,h:0.25,h:0.25,h:0.25,h:0.25,h:0.25,h:0.25,h:0.25,h:0.25,h:0.25,h:0.25,h:0.25,h:0.25,h:0.25,h:0.25,h:0.25,h:0.25,h:0.25,h:0.25,h:0.25,h:0.25,h:0.25,h:0.25,h:0.25,h:0.25,h:0.25,h:0.25,h:0.25,h:0.25,h:0.25,h:0.25,h:0.25,h:0.25,h:0.25,h:0.25,h:0.25,h:0.25,h:0.25,h:0.25,h:0.25,h:0.25,h:0.25,h:0.25,h:0.25,h:0.25,h:0.25,h:0.25,h:0.25,h:0.25,h:0.25,h:0.25,h:0.25,h:0.25", - "velocity3": 76, - "notes4": "79:1,83:1,86:2,83:1,81:1,79:2,74:1,79:1,83:2,86:1,83:1,81:2", - "velocity4": 88 + "notes": "k:2,r:2,k:2,r:2,k:1,r:1,k:2,r:1,k:1,r:1,k:1,k:1,r:1,k:0.5,r:0.5,k:1,r:1,k:1,r:1,k:1,r:1,k:0.5,r:0.5,k:1,r:1,k:1,r:1,k:1,r:1,k:1,r:1,k:4,r:4,k:2,r:2,k:1,r:1,k:1,r:1,k:0.5,k:0.5,k:0.5,k:0.5,k:0.5,k:0.5,k:0.5,k:0.5,k:0.5,k:0.5,k:0.5,k:0.5,k:0.5,k:0.5,k:0.5,k:0.5,k:0.5,k:0.5,k:0.5,k:0.5,k:0.5,k:0.5,k:0.5,k:0.5,k:0.5,k:0.5,k:0.5,k:0.5", + "notes2": "r:2,s:2,r:2,s:2,r:2,s:2,r:2,s:2,r:1,s:1,r:1,c:1,r:1,s:1,r:1,c:1,r:1,s:1,r:1,c:1,r:1,s:1,r:1,c:1,r:4,c:2,r:2,s:2,r:2,c:1,r:1,s:1,r:1,r:0.5,s:0.5,r:0.5,c:0.5,r:0.5,s:0.5,r:0.5,c:0.5,r:0.5,s:0.5,r:0.5,c:0.5,r:0.5,s:0.5,r:0.5,c:0.5,r:0.5,s:0.5,r:0.5,c:0.5,r:0.5,s:0.5,r:0.5,c:0.5,r:0.5,s:0.5,r:0.5,c:0.5,r:0.5,s:0.5,r:0.5,c:0.5", + "velocity2": 100, + "notes3": "h:1,h:1,h:1,h:1,h:1,h:1,h:1,h:1,h:1,h:1,h:1,h:1,h:1,h:1,h:1,h:1,h:0.5,h:0.5,h:0.5,h:0.5,h:0.5,h:0.5,h:0.5,h:0.5,h:0.5,h:0.5,h:0.5,h:0.5,h:0.5,h:0.5,h:0.5,h:0.5,h:0.5,h:0.5,h:0.5,h:0.5,h:0.5,h:0.5,h:0.5,h:0.5,h:0.5,h:0.5,h:0.5,h:0.5,h:0.5,h:0.5,h:0.5,h:0.5,rd:2,r:2,rd:2,r:2,h:1,h:1,h:2,h:1,h:1,rd:2,rd:0.5,h:0.5,h:0.5,h:0.5,h:0.5,h:0.5,h:0.5,h:0.5,h:0.5,h:0.5,h:0.5,h:0.5,h:0.5,h:0.5,h:0.5,h:0.5,h:0.5,h:0.5,h:0.5,h:0.5,h:0.5,h:0.5,h:0.5,h:0.5,h:0.5,h:0.5,h:0.5,h:0.5,h:0.5,h:0.5,h:0.5,rd:0.5", + "velocity3": 80, + "notes4": "r:2,79:2,83:2,86:4,83:2,81:2,79:2,79:1,83:1,86:2,88:2,86:1,83:1,81:2,83:2,79:2,74:2,74:4,r:2,79:2,81:2,83:2,81:2,79:2,79:0.5,81:0.5,83:1,86:1,88:1,91:2,88:1,86:1,83:2,81:1,79:1,74:2,79:2", + "velocity4": 90 } ] }