Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

menuband 0.4: mute toggle, sandbox-safe key capture, accent-tinted icon

- Popover: speaker icon next to MIDI mutes the local synth (state lit + MIDI
out still flow; only the built-in sampler/MIDISynth is gagged). Persists
in UserDefaults; flipping on calls synth.panic() so tails don't hang.
- LocalKeyCapture: invisible NSPanel + NSEvent monitor for keys while Menu
Band is the active app. No CGEventTap, no Accessibility prompt — the
MAS-eligible path. AppDelegate routes both global-tap and local-panel
keys through a single playKeyEvent handler.
- IconTinter: hue-rotates AppIcon.icns to the user's accent color via
NSWorkspace.setIcon, written as extended attrs so the Developer ID
signature stays intact. install.sh strips Icon\r resource fork before
codesign and fails loudly instead of silently falling back to ad-hoc.
- Voice routing: keyboard path now round-robins across 8 melodic channels
(mirrors the menubar-tap path) so rapid retriggers overlap as distinct
voices instead of stomping on channel 0. Display-note tracking keeps the
visible C4–C5 lit state honest under octave shift.
- Popover refresh: instrument readout moved to the title row, palette greys
(not hides) on MIDI mode so the popover geometry stays stable, "Mouse
Only" mode retired (local capture handles that implicitly).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

+639 -160
+2 -2
slab/menuband/Info.plist
··· 13 13 <key>CFBundlePackageType</key> 14 14 <string>APPL</string> 15 15 <key>CFBundleVersion</key> 16 - <string>3</string> 16 + <string>4</string> 17 17 <key>CFBundleShortVersionString</key> 18 - <string>0.3</string> 18 + <string>0.4</string> 19 19 <key>CFBundleInfoDictionaryVersion</key> 20 20 <string>6.0</string> 21 21 <key>CFBundleIconFile</key>
+160 -8
slab/menuband/Sources/MenuBand/AppDelegate.swift
··· 28 28 private var clickAwayMonitor: Any? 29 29 private var popoverEscMonitor: Any? 30 30 31 + /// Sandbox-friendly local key capture. Armed when the user clicks the 32 + /// menubar piano (without opening the popover). The companion ghost 33 + /// timer paints letter labels on the menubar keys briefly when armed 34 + /// or when an actual key is pressed — visual signal that "you're 35 + /// capturing, type now." 36 + private let localCapture = LocalKeyCapture() 37 + private var ghostUntil: CFTimeInterval = 0 38 + private var ghostRefreshTimer: Timer? 39 + 40 + /// Letter-wave state. Pivot is the most recently lit display note; 41 + /// `phaseStartedAt` is when the current direction began; `fadingIn` 42 + /// indicates direction. The renderer queries per-cell alpha derived 43 + /// from these — fade-in ripples outward from the pivot, fade-out 44 + /// retreats inward (far cells fade first, the pivot last). 45 + private var letterPivot: UInt8 = 60 46 + private var letterPhaseStartedAt: CFTimeInterval = 0 47 + private var letterFadingIn: Bool = false 48 + private var letterFadeTimer: Timer? 49 + private static let letterWaveStep: CFTimeInterval = 0.025 // 25 ms per cell of distance 50 + private static let letterFadeInDur: CFTimeInterval = 0.08 51 + private static let letterFadeOutDur: CFTimeInterval = 0.18 52 + 31 53 func applicationDidFinishLaunching(_ notification: Notification) { 32 54 debugLog("applicationDidFinishLaunching pid=\(ProcessInfo.processInfo.processIdentifier)") 33 55 Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { _ in ··· 76 98 hotkey.register(keyCode: UInt32(kVK_ANSI_P), modifiers: modMask) 77 99 typeModeHotkey = hotkey 78 100 101 + // Local key capture wiring. Routes keys to the same note logic the 102 + // global tap uses, with the ghost-label flash on every press so the 103 + // user sees the layout dynamically appear while typing. 104 + localCapture.onKey = { [weak self] keyCode, isDown, isRepeat, flags in 105 + guard let self = self else { return false } 106 + // Escape disarms capture explicitly. Useful when the user 107 + // wants to release focus without clicking another app. 108 + if isDown && keyCode == 53 /* kVK_Escape */ { 109 + NSSound(named: NSSound.Name("Tink"))?.play() 110 + self.localCapture.disarm() 111 + return true 112 + } 113 + let consumed = self.menuBand.handleLocalKey( 114 + keyCode: keyCode, isDown: isDown, isRepeat: isRepeat, flags: flags 115 + ) 116 + if consumed && isDown { 117 + // Use the most-recent lit display note as the wave pivot 118 + // so the ripple emanates from whichever key the user just 119 + // played. `litNotes` is updated synchronously on this 120 + // thread, so it already contains the new note. 121 + let pivot = self.menuBand.litNotes.max() ?? 60 122 + DispatchQueue.main.async { self.extendGhost(0.4, pivot: pivot) } 123 + } 124 + return consumed 125 + } 126 + localCapture.onCaptureEnd = { [weak self] in 127 + // Focus lost (user clicked another app). Drop the ghost and 128 + // any held notes so we don't leave anything hanging. 129 + self?.ghostUntil = 0 130 + self?.ghostRefreshTimer?.invalidate() 131 + self?.ghostRefreshTimer = nil 132 + self?.updateIcon() 133 + } 134 + 79 135 // Pre-instance the popover + force its view to load now so the 80 136 // first click pops it instantly. With `animates = false` the 81 137 // open/close has no transition — it's a snap, much more "playable" ··· 94 150 _ = vc.view 95 151 96 152 startAdaptiveLayoutChecks() 153 + 154 + // Retint the bundle's Finder icon to the user's accent color. 155 + // Stored as an xattr on the bundle folder, so the signed payload 156 + // isn't modified. Refreshed whenever the accent changes. 157 + IconTinter.applyTintedIcon() 158 + NotificationCenter.default.addObserver( 159 + forName: NSColor.systemColorsDidChangeNotification, 160 + object: nil, queue: .main 161 + ) { _ in 162 + IconTinter.applyTintedIcon() 163 + } 97 164 } 98 165 99 166 // MARK: - Adaptive menubar layout ··· 187 254 // 2-octave Notepat layout area) — Ableton is drawn with negative 188 255 // space on the right — so the status item slot never resizes and 189 256 // the popover anchor stays put. 257 + // 258 + // Ghost-label flash: when the user clicks the menubar piano (or 259 + // types while armed), letters render on the keys for ~0.5–0.7 s. 260 + // It's a temporal "you're capturing right now" hint, not a 261 + // permanent overlay. 190 262 KeyboardIconRenderer.activeKeymap = menuBand.keymap 191 263 statusItem.length = KeyboardIconRenderer.imageSize.width 192 264 button.image = KeyboardIconRenderer.image( ··· 194 266 enabled: menuBand.midiMode, 195 267 typeMode: menuBand.typeMode, 196 268 melodicProgram: menuBand.melodicProgram, 197 - hovered: hoveredElement 269 + hovered: hoveredElement, 270 + letterAlpha: { [weak self] midi in 271 + self?.letterAlpha(for: midi) ?? 0 272 + } 198 273 ) 199 274 // Force a synchronous redraw — the click drag-loop runs the runloop 200 275 // in `eventTracking` mode and has been swallowing the next CA flush ··· 204 279 button.displayIfNeeded() 205 280 } 206 281 282 + /// Extend the letter ghost duration and pivot the wave at the given 283 + /// display note. Each keypress restarts (or extends) the fade-in 284 + /// from that pivot; when the ghost expires the wave reverses outward 285 + /// (far cells fade out first). The animation tick drives 60 Hz 286 + /// redraws while the fade is in progress, then auto-stops. 287 + private func extendGhost(_ duration: CFTimeInterval, pivot: UInt8? = nil) { 288 + let now = CACurrentMediaTime() 289 + let target = now + duration 290 + if target > ghostUntil { ghostUntil = target } 291 + if let pivot = pivot { letterPivot = pivot } 292 + // Restart the fade-in phase from "now" so cells progressively 293 + // re-attack from the new pivot. Already-bright cells stay bright 294 + // because the alpha formula is monotonic for time within a phase. 295 + if !letterFadingIn { 296 + letterFadingIn = true 297 + letterPhaseStartedAt = now 298 + } 299 + ghostRefreshTimer?.invalidate() 300 + ghostRefreshTimer = Timer.scheduledTimer(withTimeInterval: duration + 0.02, repeats: false) { [weak self] _ in 301 + self?.startLetterFadeOut() 302 + } 303 + startLetterFadeTickIfNeeded() 304 + updateIcon() 305 + } 306 + 307 + private func startLetterFadeOut() { 308 + letterFadingIn = false 309 + letterPhaseStartedAt = CACurrentMediaTime() 310 + startLetterFadeTickIfNeeded() 311 + updateIcon() 312 + } 313 + 314 + private func startLetterFadeTickIfNeeded() { 315 + guard letterFadeTimer == nil else { return } 316 + letterFadeTimer = Timer.scheduledTimer(withTimeInterval: 1.0/60.0, repeats: true) { [weak self] _ in 317 + self?.tickLetterFade() 318 + } 319 + } 320 + 321 + private func tickLetterFade() { 322 + // Stable when fade-out has had enough time for the slowest 323 + // (closest-to-pivot) cell to reach 0. Stop the timer there to 324 + // avoid burning idle CPU. 325 + let now = CACurrentMediaTime() 326 + let phase = now - letterPhaseStartedAt 327 + let maxDist: CFTimeInterval = 24 328 + let stableAt = letterFadingIn 329 + ? maxDist * Self.letterWaveStep + Self.letterFadeInDur 330 + : maxDist * Self.letterWaveStep + Self.letterFadeOutDur 331 + if !letterFadingIn && phase >= stableAt { 332 + letterFadeTimer?.invalidate() 333 + letterFadeTimer = nil 334 + } 335 + updateIcon() 336 + } 337 + 338 + /// Per-cell alpha based on radial distance from the pivot. Fade-in 339 + /// starts at the pivot and ripples outward; fade-out starts at the 340 + /// outermost cell and retreats toward the pivot. 341 + private func letterAlpha(for midi: UInt8) -> CGFloat { 342 + let dist = CFTimeInterval(abs(Int(midi) - Int(letterPivot))) 343 + let phase = CACurrentMediaTime() - letterPhaseStartedAt 344 + if letterFadingIn { 345 + let cellStart = dist * Self.letterWaveStep 346 + let t = (phase - cellStart) / Self.letterFadeInDur 347 + return CGFloat(max(0, min(1, t))) 348 + } else { 349 + // Fade-out delay: far cells (large dist) start first. 350 + let maxDist: CFTimeInterval = 24 351 + let cellStart = (maxDist - dist) * Self.letterWaveStep 352 + let t = (phase - cellStart) / Self.letterFadeOutDur 353 + return CGFloat(max(0, min(1, 1 - t))) 354 + } 355 + } 356 + 207 357 // MARK: - Hover 208 358 209 359 private var lastHoverLogTime: TimeInterval = 0 ··· 254 404 } 255 405 256 406 if isRight || isCtrl { 257 - NSSound(named: NSSound.Name("Tink"))?.play() 258 407 showPopover() 259 408 return 260 409 } ··· 276 425 let startNote: UInt8 277 426 switch initial { 278 427 case .openSettings: 279 - NSSound(named: NSSound.Name("Tink"))?.play() 280 428 showPopover() 281 429 return 282 430 case .note(let n): ··· 288 436 let initialPt = imagePoint(from: downEvent.locationInWindow) 289 437 let (vel0, pan0) = expression(for: startNote, at: initialPt) 290 438 menuBand.startTapNote(startNote, velocity: vel0, pan: pan0) 439 + // Arm sandbox-friendly local capture on a real piano click. We 440 + // skip arming when global TYPE mode is already on — the global 441 + // tap is already handling keys, doubling up would re-trigger 442 + // every note. No letter flash on click: the label overlay is 443 + // reserved for actual key presses, so the menubar stays clean 444 + // when you're just tapping the piano with the mouse. 445 + if !menuBand.typeMode { 446 + localCapture.arm() 447 + } 291 448 var current: UInt8? = startNote 292 449 while let next = NSApp.nextEvent( 293 450 matching: [.leftMouseDragged, .leftMouseUp], ··· 366 523 // by default; without this you have to click into the popover 367 524 // once before its controls react. 368 525 NSApp.activate(ignoringOtherApps: true) 369 - // Sharp tactile click on open — the settings chip is the only 370 - // entry point to the popover, so this becomes the "menu opening" 371 - // sound. Tink is short and unmistakable; a richer custom sample 372 - // would need an asset bundle, and Tink ships on every macOS. 373 - NSSound(named: NSSound.Name("Tink"))?.play() 374 526 popover.show(relativeTo: anchor, of: button, preferredEdge: .minY) 375 527 DispatchQueue.main.async { 376 528 self.popover.contentViewController?.view.window?.makeKey()
+89
slab/menuband/Sources/MenuBand/IconTinter.swift
··· 1 + import AppKit 2 + import CoreImage 3 + 4 + /// Retints the app's Finder/About/Applications icon to match the user's 5 + /// system accent color. The shipped `AppIcon.icns` is a purple gradient 6 + /// with a white piano in the middle; we hue-rotate the entire icon by 7 + /// `(accentHue − purpleHue)` so the gradient adopts the accent while 8 + /// the white/black piano stays untouched (hue rotation is a no-op on 9 + /// achromatic pixels). 10 + /// 11 + /// Application is via `NSWorkspace.setIcon(_:forFile:)` on the bundle 12 + /// path. On macOS that stores the icon resource as extended attributes 13 + /// on the bundle directory itself — not inside the signed payload — so 14 + /// the Developer ID signature stays valid. We re-verify after each 15 + /// retint just in case Apple ever changes that. 16 + enum IconTinter { 17 + /// Approximate hue of the shipped icon's purple gradient. The icon 18 + /// design is symmetric enough that any value in 280–300° looks fine, 19 + /// since CIHueAdjust rotates relative to whatever the source hue is. 20 + private static let originalHueDeg: CGFloat = 290 21 + 22 + /// Idempotent: read the bundled icon, hue-rotate to current accent, 23 + /// write back via NSWorkspace. Safe to call repeatedly (e.g., after 24 + /// every accent-color change notification). No-op if the bundle's 25 + /// AppIcon.icns can't be loaded. 26 + static func applyTintedIcon() { 27 + guard let tinted = tintedIcon() else { return } 28 + NSWorkspace.shared.setIcon(tinted, forFile: Bundle.main.bundlePath, options: []) 29 + } 30 + 31 + /// Build the retinted NSImage. Returned image carries every 32 + /// representation of the source `.icns` at the same hue rotation, 33 + /// so Finder picks the right size automatically. 34 + static func tintedIcon() -> NSImage? { 35 + guard let baseURL = Bundle.main.url(forResource: "AppIcon", withExtension: "icns"), 36 + let base = NSImage(contentsOf: baseURL) else { return nil } 37 + let accent = NSColor.controlAccentColor.usingColorSpace(.deviceRGB) ?? .systemBlue 38 + var hue: CGFloat = 0, sat: CGFloat = 0, bri: CGFloat = 0, a: CGFloat = 0 39 + accent.getHue(&hue, saturation: &sat, brightness: &bri, alpha: &a) 40 + let accentHueRad = hue * 2 * .pi 41 + let originalHueRad = (originalHueDeg / 360) * 2 * .pi 42 + let delta = accentHueRad - originalHueRad 43 + 44 + // Rebuild the multi-rep NSImage by hue-rotating each rep 45 + // separately. Doing the filter once on a flattened TIFF would 46 + // collapse the icon down to one resolution, defeating the 47 + // multi-size dance Finder does at different zoom levels. 48 + let result = NSImage(size: base.size) 49 + for rep in base.representations { 50 + guard let bitmap = rep as? NSBitmapImageRep ?? bitmap(from: rep), 51 + let cg = bitmap.cgImage else { continue } 52 + guard let rotated = hueRotate(cg, by: delta) else { continue } 53 + let newRep = NSBitmapImageRep(cgImage: rotated) 54 + newRep.size = bitmap.size 55 + result.addRepresentation(newRep) 56 + } 57 + return result.representations.isEmpty ? nil : result 58 + } 59 + 60 + private static func bitmap(from rep: NSImageRep) -> NSBitmapImageRep? { 61 + // Some reps (PDF, vector) need rasterization first. 62 + let size = rep.size 63 + guard size.width > 0, size.height > 0 else { return nil } 64 + let bm = NSBitmapImageRep( 65 + bitmapDataPlanes: nil, 66 + pixelsWide: Int(size.width * 2), pixelsHigh: Int(size.height * 2), 67 + bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, 68 + isPlanar: false, colorSpaceName: .deviceRGB, 69 + bytesPerRow: 0, bitsPerPixel: 0 70 + ) 71 + bm?.size = size 72 + guard let bm = bm else { return nil } 73 + NSGraphicsContext.saveGraphicsState() 74 + NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: bm) 75 + rep.draw(in: NSRect(origin: .zero, size: size)) 76 + NSGraphicsContext.restoreGraphicsState() 77 + return bm 78 + } 79 + 80 + private static func hueRotate(_ cg: CGImage, by radians: CGFloat) -> CGImage? { 81 + let ci = CIImage(cgImage: cg) 82 + guard let filter = CIFilter(name: "CIHueAdjust") else { return nil } 83 + filter.setValue(ci, forKey: kCIInputImageKey) 84 + filter.setValue(radians, forKey: kCIInputAngleKey) 85 + guard let output = filter.outputImage else { return nil } 86 + let context = CIContext(options: [.useSoftwareRenderer: false]) 87 + return context.createCGImage(output, from: output.extent) 88 + } 89 + }
+40 -9
slab/menuband/Sources/MenuBand/KeyboardIconRenderer.swift
··· 157 157 enabled: Bool, 158 158 typeMode: Bool = false, 159 159 melodicProgram: UInt8 = 0, 160 - hovered: HitResult? = nil) -> NSImage { 160 + hovered: HitResult? = nil, 161 + letterAlpha: ((UInt8) -> CGFloat)? = nil) -> NSImage { 161 162 let whites = whiteList() 162 163 var whiteIndex: [Int: Int] = [:] 163 164 for (i, m) in whites.enumerated() { whiteIndex[m] = i } ··· 213 214 groove.setStroke() 214 215 path.lineWidth = 0.7 215 216 path.stroke() 216 - if typeMode, let letter = labelByMidi[m] { 217 - drawWhiteLabel(letter, in: rect, lit: isLit) 217 + // Lit keys always wear their letter at full opacity (a 218 + // mouse-press tap reveals just that letter). For unlit 219 + // keys, the per-key `letterAlpha` closure drives a wave 220 + // fade-in / fade-out that ripples outward from whichever 221 + // key the user is currently playing. Falls back to the 222 + // legacy binary `typeMode` rendering when no closure is 223 + // supplied (e.g., previews that don't drive animation). 224 + if let letter = labelByMidi[m] { 225 + let a: CGFloat 226 + if isLit { 227 + a = 1.0 228 + } else if let closure = letterAlpha { 229 + a = closure(UInt8(m)) 230 + } else { 231 + a = typeMode ? 1.0 : 0.0 232 + } 233 + if a > 0.01 { 234 + drawWhiteLabel(letter, in: rect, lit: isLit, alpha: a) 235 + } 218 236 } 219 237 } 220 238 for m in firstMidi...lastMidi where !isWhite(m) { ··· 239 257 groove.setStroke() 240 258 path.lineWidth = 0.6 241 259 path.stroke() 242 - if typeMode, let letter = labelByMidi[m] { 243 - drawBlackLabel(letter, in: rect, lit: isLit) 260 + if let letter = labelByMidi[m] { 261 + let a: CGFloat 262 + if isLit { 263 + a = 1.0 264 + } else if let closure = letterAlpha { 265 + a = closure(UInt8(m)) 266 + } else { 267 + a = typeMode ? 1.0 : 0.0 268 + } 269 + if a > 0.01 { 270 + drawBlackLabel(letter, in: rect, lit: isLit, alpha: a) 271 + } 244 272 } 245 273 } 246 274 NSGraphicsContext.restoreGraphicsState() ··· 405 433 406 434 // MARK: - Key labels 407 435 408 - private static func drawWhiteLabel(_ text: String, in rect: NSRect, lit: Bool) { 436 + private static func drawWhiteLabel(_ text: String, in rect: NSRect, lit: Bool, alpha: CGFloat = 1.0) { 437 + guard alpha > 0.01 else { return } 438 + let base: NSColor = lit ? .white : NSColor(white: 0.28, alpha: 1.0) 409 439 let attrs: [NSAttributedString.Key: Any] = [ 410 440 .font: NSFont.systemFont(ofSize: 9.0, weight: .heavy), 411 - .foregroundColor: lit ? NSColor.white : NSColor(white: 0.28, alpha: 1.0), 441 + .foregroundColor: base.withAlphaComponent(alpha), 412 442 ] 413 443 let str = NSAttributedString(string: text, attributes: attrs) 414 444 let size = str.size() 415 445 str.draw(at: NSPoint(x: rect.midX - size.width / 2, y: rect.minY + 0.4)) 416 446 } 417 447 418 - private static func drawBlackLabel(_ text: String, in rect: NSRect, lit: Bool) { 448 + private static func drawBlackLabel(_ text: String, in rect: NSRect, lit: Bool, alpha: CGFloat = 1.0) { 449 + guard alpha > 0.01 else { return } 419 450 let attrs: [NSAttributedString.Key: Any] = [ 420 451 .font: NSFont.systemFont(ofSize: 8.0, weight: .heavy), 421 - .foregroundColor: NSColor.white.withAlphaComponent(0.96), 452 + .foregroundColor: NSColor.white.withAlphaComponent(0.96 * alpha), 422 453 ] 423 454 let str = NSAttributedString(string: text, attributes: attrs) 424 455 let size = str.size()
+106
slab/menuband/Sources/MenuBand/LocalKeyCapture.swift
··· 1 + import AppKit 2 + 3 + /// Sandbox-friendly key capture: an invisible 1×1 NSPanel that becomes the 4 + /// key window after the user clicks the menubar piano. A local NSEvent 5 + /// monitor catches keyDown / keyUp while the panel is key — no 6 + /// `CGEventTap`, no Accessibility prompt, no global hook. 7 + /// 8 + /// Tradeoff: this only captures keys while Menu Band is the active app. 9 + /// The moment the user clicks any other app the panel resigns key and 10 + /// capture ends. That's the whole point — it's what makes this MAS-safe. 11 + /// The current global-tap mode (Notepat / Ableton in the popover) survives 12 + /// any focus change, but requires Accessibility and isn't App-Store-eligible. 13 + final class LocalKeyCapture { 14 + /// Called on every keyDown the panel observes. Return `true` to consume 15 + /// (so cmd-q etc. can still fall through if false). Receives the same 16 + /// (keyCode, isDown, isRepeat, flags) shape as the global tap so the 17 + /// AppDelegate can route both paths through one handler. 18 + var onKey: ((UInt16, Bool, Bool, NSEvent.ModifierFlags) -> Bool)? 19 + /// Called when the panel resigns key — capture has ended naturally 20 + /// because the user clicked another app. 21 + var onCaptureEnd: (() -> Void)? 22 + 23 + private var panel: NSPanel? 24 + private var monitor: Any? 25 + private var resignKeyObserver: NSObjectProtocol? 26 + private(set) var isArmed = false 27 + 28 + /// Bring up the invisible panel and start listening. Idempotent — if 29 + /// already armed, just refreshes the panel as key. 30 + func arm() { 31 + if panel == nil { buildPanel() } 32 + guard let panel = panel else { return } 33 + // Activate the app + make panel key so keyDown events route here 34 + // instead of the previously-foreground app. Without `activate`, our 35 + // panel can't become key and keys go elsewhere. 36 + NSApp.activate(ignoringOtherApps: true) 37 + panel.makeKeyAndOrderFront(nil) 38 + if monitor == nil { 39 + monitor = NSEvent.addLocalMonitorForEvents(matching: [.keyDown, .keyUp]) { [weak self] event in 40 + guard let self = self else { return event } 41 + let isDown = (event.type == .keyDown) 42 + let consumed = self.onKey?(event.keyCode, isDown, event.isARepeat, event.modifierFlags) ?? false 43 + return consumed ? nil : event 44 + } 45 + } 46 + if resignKeyObserver == nil { 47 + resignKeyObserver = NotificationCenter.default.addObserver( 48 + forName: NSWindow.didResignKeyNotification, 49 + object: panel, queue: .main 50 + ) { [weak self] _ in 51 + self?.disarm() 52 + } 53 + } 54 + isArmed = true 55 + } 56 + 57 + /// Tear down the panel + monitor. Called when the user clicks another 58 + /// app (panel resigns key) or explicitly when we want to drop capture. 59 + func disarm() { 60 + guard isArmed else { return } 61 + isArmed = false 62 + if let m = monitor { 63 + NSEvent.removeMonitor(m) 64 + monitor = nil 65 + } 66 + if let obs = resignKeyObserver { 67 + NotificationCenter.default.removeObserver(obs) 68 + resignKeyObserver = nil 69 + } 70 + panel?.orderOut(nil) 71 + onCaptureEnd?() 72 + } 73 + 74 + private func buildPanel() { 75 + // 1×1 px, off-screen, fully transparent. Borderless NSWindow / 76 + // NSPanel return `false` from `canBecomeKey` by default — 77 + // makeKeyAndOrderFront silently fails and keyDown events never 78 + // route to our local monitor. The KeyCapturePanel subclass below 79 + // overrides that so the panel actually becomes key. 80 + let p = KeyCapturePanel( 81 + contentRect: NSRect(x: -1000, y: -1000, width: 1, height: 1), 82 + styleMask: [.borderless, .nonactivatingPanel], 83 + backing: .buffered, defer: false 84 + ) 85 + p.isOpaque = false 86 + p.backgroundColor = .clear 87 + p.alphaValue = 0 88 + p.hasShadow = false 89 + p.level = .statusBar 90 + p.hidesOnDeactivate = false 91 + p.canHide = false 92 + p.acceptsMouseMovedEvents = false 93 + panel = p 94 + } 95 + 96 + deinit { disarm() } 97 + } 98 + 99 + /// `NSPanel` subclass that overrides `canBecomeKey` to return true. Without 100 + /// this, borderless panels silently refuse to become key window — events 101 + /// route nowhere and the system beep fires when we expected note playback. 102 + private final class KeyCapturePanel: NSPanel { 103 + override var canBecomeKey: Bool { true } 104 + // canBecomeMain stays default (false). We don't want to look like the 105 + // primary window — just receive keyboard events. 106 + }
+122 -46
slab/menuband/Sources/MenuBand/MenuBandController.swift
··· 7 7 private let synth = MenuBandSynth() 8 8 private var keyTap: KeyEventTap? 9 9 private var heldNotes: [UInt16: UInt8] = [:] 10 + /// Synth channel each held keystroke is voicing on. Mirrors 11 + /// `tapNoteChannel` for the menubar-tap path: round-robin across 12 + /// melodic channels 0–7 lets fast same-key retriggers overlap as 13 + /// distinct voices instead of voice-stealing on channel 0. Same lock. 14 + private var heldKeyChannel: [UInt16: UInt8] = [:] 15 + /// Display-note (clamped into the menubar piano's C4–C5 range) for 16 + /// each held key. Lets keyUp remove the visually-lit cell even when 17 + /// the audio note was octave-shifted out of the visible range. 18 + private var heldKeyDisplayNote: [UInt16: UInt8] = [:] 10 19 private let heldLock = NSLock() 11 20 12 21 private let midiModeKey = "notepat.midiMode" ··· 14 23 private let octaveShiftKey = "notepat.octaveShift" 15 24 private let melodicProgramKey = "notepat.melodicProgram" 16 25 private let keymapKey = "notepat.keymap" 26 + private let mutedKey = "notepat.muted" 17 27 18 28 // Visual state — accessed only on the main thread. 19 29 private(set) var litNotes: Set<UInt8> = [] ··· 31 41 UserDefaults.standard.bool(forKey: typeModeKey) 32 42 } 33 43 44 + /// When on, the local synth is silent — note triggers still update lit 45 + /// state and (in MIDI mode) still send out the virtual port, but the 46 + /// built-in sampler/MIDISynth doesn't sound. Independent of MIDI mode 47 + /// so a user can keep MIDI off and still mute the local synth. 48 + var muted: Bool { 49 + UserDefaults.standard.bool(forKey: mutedKey) 50 + } 51 + 52 + func toggleMuted() { 53 + let now = !muted 54 + UserDefaults.standard.set(now, forKey: mutedKey) 55 + if now { 56 + // Cut anything currently sounding so a long-tail note doesn't 57 + // hang past the moment the user hit mute. 58 + synth.panic() 59 + } 60 + onChange?() 61 + } 62 + 34 63 /// Audition the currently-loaded melodic program through the local 35 64 /// synth, regardless of MIDI mode. Used by the instrument-list click 36 65 /// handler so the user *always* hears their instrument pick, even when ··· 74 103 return 75 104 } 76 105 synth.setMelodicProgram(prog) 77 - guard !midiMode else { return } 106 + guard !midiMode, !muted else { return } 78 107 let note: UInt8 = 60 79 108 previewNote = note 80 109 // When the synth supports instant program changes (MIDISynth backend ··· 99 128 } 100 129 101 130 func auditionCurrentProgram() { 131 + guard !muted else { return } 102 132 let note: UInt8 = 60 103 133 debugLog("audition: synth.noteOn 60 (program \(melodicProgram))") 104 134 synth.noteOn(note, velocity: 100, channel: 0) ··· 397 427 let midiCh: UInt8 = isDrum ? 9 : 0 398 428 tapNoteChannel[midiNote] = synthCh 399 429 midi.sendCC(10, value: pan, channel: midiCh) 400 - if !midiMode { synth.noteOn(midiNote, velocity: velocity, channel: synthCh) } 430 + if !midiMode && !muted { synth.noteOn(midiNote, velocity: velocity, channel: synthCh) } 401 431 midi.noteOn(midiNote, velocity: velocity, channel: midiCh) 402 432 // Lit state is main-thread-only; update synchronously so the menubar 403 433 // redraws within the same runloop pass as the click. Dispatching async ··· 437 467 synth.noteOff(midiNote, channel: synthCh) 438 468 } 439 469 midi.noteOff(midiNote, channel: midiCh) 440 - // Visual cleanup deferred so we don't pay image-render cost in the 441 - // mouse-up handler. 470 + // Release the visual immediately on mouse-up. The earlier 471 + // minVisibleSeconds floor read as visual lag — the user 472 + // perceives it as the key sticking down past the click. Snap-up 473 + // matches the keyboard path now. 442 474 DispatchQueue.main.async { [weak self] in 443 475 guard let self = self else { return } 444 - let downAt = self.litDownAt.removeValue(forKey: midiNote) ?? 0 445 - let held = CACurrentMediaTime() - downAt 446 - let extinguish = { [weak self] in 447 - guard let self = self else { return } 448 - if self.litNotes.remove(midiNote) != nil { 449 - self.onLitChanged?() 450 - } 451 - } 452 - if held < self.minVisibleSeconds { 453 - DispatchQueue.main.asyncAfter(deadline: .now() + (self.minVisibleSeconds - held)) { 454 - extinguish() 455 - } 456 - } else { 457 - extinguish() 476 + self.litDownAt.removeValue(forKey: midiNote) 477 + if self.litNotes.remove(midiNote) != nil { 478 + self.onLitChanged?() 458 479 } 459 480 } 460 481 } ··· 528 549 } 529 550 return true 530 551 } 552 + let hasMod = flags.contains(.maskCommand) || flags.contains(.maskControl) || flags.contains(.maskAlternate) 553 + return playKeyEvent(keyCode: keyCode, isDown: isDown, isRepeat: isRepeat, hasModifier: hasMod) 554 + } 555 + 556 + /// Sandbox-friendly key path: same note logic as the global tap, but 557 + /// driven by a local NSEvent monitor on the AppDelegate's invisible 558 + /// capture panel. No TYPE-mode escape semantics — this path activates 559 + /// and deactivates with the panel's key-window state, so an "exit" 560 + /// keystroke isn't needed. 561 + @discardableResult 562 + func handleLocalKey(keyCode: UInt16, isDown: Bool, isRepeat: Bool, flags: NSEvent.ModifierFlags) -> Bool { 563 + let hasMod = flags.contains(.command) || flags.contains(.control) || flags.contains(.option) 564 + return playKeyEvent(keyCode: keyCode, isDown: isDown, isRepeat: isRepeat, hasModifier: hasMod) 565 + } 566 + 567 + /// Shared note logic for both the global CGEventTap path and the 568 + /// local NSEvent panel path. Returns true if the keystroke was 569 + /// consumed (mapped to a note); false if it should pass through. 570 + @discardableResult 571 + private func playKeyEvent(keyCode: UInt16, isDown: Bool, isRepeat: Bool, hasModifier: Bool) -> Bool { 531 572 // Modifier combos pass through so cmd-c, cmd-tab etc. work as usual. 532 - if flags.contains(.maskCommand) || flags.contains(.maskControl) || flags.contains(.maskAlternate) { return false } 573 + if hasModifier { return false } 533 574 534 575 let shift = octaveShift // a single UserDefaults read; cheap 535 576 ··· 540 581 // through to the focused app. 541 582 return true 542 583 } 584 + // Match the menubar-tap path's voice allocation: round-robin 585 + // across 8 melodic channels so rapid retriggers don't stomp 586 + // each other on channel 0. Without this, keyboard play sounds 587 + // noticeably different from mouse play (more voice-stealing, 588 + // sharper attack tail-off). MIDI out still goes to channel 0 589 + // so DAW tracks listening on one channel get every note. 590 + let synthCh = nextMelodicChannel() 591 + let displayNote: UInt8 = { 592 + let v = Int(note) - shift * 12 593 + let clamped = max(60, min(83, v)) 594 + return UInt8(clamped) 595 + }() 543 596 heldLock.lock() 544 - let prev = heldNotes[keyCode] 597 + let prevNote = heldNotes[keyCode] 598 + let prevCh = heldKeyChannel[keyCode] 599 + let prevDisplay = heldKeyDisplayNote[keyCode] 545 600 heldNotes[keyCode] = note 601 + heldKeyChannel[keyCode] = synthCh 602 + heldKeyDisplayNote[keyCode] = displayNote 546 603 heldLock.unlock() 547 - if let prev = prev { 548 - if !midiMode { synth.noteOff(prev) } 549 - midi.noteOff(prev) 604 + if let prevNote = prevNote { 605 + if !midiMode { synth.noteOff(prevNote, channel: prevCh ?? 0) } 606 + midi.noteOff(prevNote) 550 607 } 551 - if !midiMode { synth.noteOn(note) } 608 + if !midiMode && !muted { synth.noteOn(note, velocity: 100, channel: synthCh) } 552 609 midi.noteOn(note) 553 - DispatchQueue.main.async { [weak self] in 610 + // The menubar piano renders a fixed C4–C5 window; the audio 611 + // path plays at the user's full octave-shifted pitch. To keep 612 + // the visual lit state honest, mark the *display* note (note 613 + // minus the octave delta, clamped to the visible range) as 614 + // pressed — keyboard 'z' at octaveShift=3 plays MIDI 96 but 615 + // lights up the visible C4 (MIDI 60). Lit state must update 616 + // synchronously when called on main (the local-capture path) 617 + // so the menubar redraws within the same runloop pass as the 618 + // keystroke; the global tap runs on a CGEventTap background 619 + // thread, hop to main there. 620 + let setLit = { [weak self] in 554 621 guard let self = self else { return } 555 - self.litDownAt[note] = CACurrentMediaTime() 556 - if self.litNotes.insert(note).inserted { 622 + if let prevDisplay = prevDisplay, prevDisplay != displayNote { 623 + self.litDownAt.removeValue(forKey: prevDisplay) 624 + if self.litNotes.remove(prevDisplay) != nil { 625 + self.onLitChanged?() 626 + } 627 + } 628 + self.litDownAt[displayNote] = CACurrentMediaTime() 629 + if self.litNotes.insert(displayNote).inserted { 557 630 self.onLitChanged?() 558 631 } 559 632 } 633 + if Thread.isMainThread { setLit() } else { DispatchQueue.main.async(execute: setLit) } 560 634 return true 561 635 } else { 562 636 heldLock.lock() 563 637 let note = heldNotes.removeValue(forKey: keyCode) 638 + let synthCh = heldKeyChannel.removeValue(forKey: keyCode) ?? 0 639 + let displayNote = heldKeyDisplayNote.removeValue(forKey: keyCode) 564 640 heldLock.unlock() 565 641 guard let releasedNote = note else { return true } // consume the up too 566 - if !midiMode { synth.noteOff(releasedNote) } 642 + if !midiMode { synth.noteOff(releasedNote, channel: synthCh) } 567 643 midi.noteOff(releasedNote) 568 - DispatchQueue.main.async { [weak self] in 644 + // Extinguish the *display* lit cell, not the played pitch — 645 + // the played pitch may be far outside the visible range when 646 + // octaveShift > 0, but the lit highlight always lives in 60–83. 647 + // No minVisibleSeconds delay: release the visual the instant 648 + // the key is released for a snappy press-and-up feel. 649 + let visualNote = displayNote ?? releasedNote 650 + let extinguish = { [weak self] in 569 651 guard let self = self else { return } 570 - let downAt = self.litDownAt.removeValue(forKey: releasedNote) ?? 0 571 - let held = CACurrentMediaTime() - downAt 572 - let extinguish = { [weak self] in 573 - guard let self = self else { return } 574 - if self.litNotes.remove(releasedNote) != nil { 575 - self.onLitChanged?() 576 - } 577 - } 578 - if held < self.minVisibleSeconds { 579 - DispatchQueue.main.asyncAfter(deadline: .now() + (self.minVisibleSeconds - held)) { 580 - extinguish() 581 - } 582 - } else { 583 - extinguish() 652 + self.litDownAt.removeValue(forKey: visualNote) 653 + if self.litNotes.remove(visualNote) != nil { 654 + self.onLitChanged?() 584 655 } 585 656 } 657 + if Thread.isMainThread { extinguish() } else { DispatchQueue.main.async(execute: extinguish) } 586 658 return true 587 659 } 588 660 } 589 661 590 662 private func releaseAllHeldNotes() { 591 663 heldLock.lock() 592 - let snapshot = heldNotes 664 + let noteSnapshot = heldNotes 665 + let chanSnapshot = heldKeyChannel 593 666 heldNotes.removeAll() 667 + heldKeyChannel.removeAll() 668 + heldKeyDisplayNote.removeAll() 594 669 heldLock.unlock() 595 - for (_, note) in snapshot { 596 - synth.noteOff(note) 670 + for (keyCode, note) in noteSnapshot { 671 + let ch = chanSnapshot[keyCode] ?? 0 672 + synth.noteOff(note, channel: ch) 597 673 midi.noteOff(note) 598 674 } 599 675 midi.sendAllNotesOff()
+102 -83
slab/menuband/Sources/MenuBand/MenuBandPopover.swift
··· 66 66 private var modeButtons: [NSButton] = [] // vertical stack: Mouse Only / Notepat.com / Ableton MIDI Keys 67 67 private var midiSwitch: NSSwitch! 68 68 private var midiInlineLabel: NSTextField! 69 + private var muteButton: NSButton! 69 70 private var midiSelfTestLabel: NSTextField! // legacy — created but never added to stack 70 71 private var instrumentList: InstrumentListView! 71 72 private var instrumentReadout: NSTextField! 72 73 private var instrumentLabel: NSTextField! 74 + private var instrumentTitleRow: NSStackView! 73 75 private var instrumentSeparator: NSView! 74 76 private var octaveStepper: NSStepper! 75 77 private var octaveLabel: NSTextField! ··· 193 195 // the MIDI pair pins RIGHT. 194 196 titleRow.addArrangedSubview(titleSpacer) 195 197 198 + // Mute toggle — small speaker icon to the left of MIDI. When on, the 199 + // local synth is silent (lit state + MIDI port still update so the 200 + // visual + DAW paths keep working — only the built-in instrument is 201 + // gagged). Lives in the title row to stay out of the main controls. 202 + let speakerConfig = NSImage.SymbolConfiguration(pointSize: 13, 203 + weight: .semibold) 204 + muteButton = NSButton() 205 + muteButton.isBordered = false 206 + muteButton.bezelStyle = .inline 207 + muteButton.imagePosition = .imageOnly 208 + muteButton.imageScaling = .scaleProportionallyDown 209 + muteButton.target = self 210 + muteButton.action = #selector(muteButtonClicked(_:)) 211 + muteButton.image = NSImage(systemSymbolName: "speaker.fill", 212 + accessibilityDescription: "Mute local synth")? 213 + .withSymbolConfiguration(speakerConfig) 214 + muteButton.contentTintColor = .secondaryLabelColor 215 + muteButton.toolTip = "Mute local synth" 216 + titleRow.addArrangedSubview(muteButton) 217 + titleRow.setCustomSpacing(8, after: muteButton) 218 + 196 219 // MIDI toggle — tucked into the title row instead of its own panel. 197 220 midiSwitch = NSSwitch() 198 221 midiSwitch.target = self ··· 262 285 // text so the mode is recognizable at a glance. 263 286 let modeSymbolConfig = NSImage.SymbolConfiguration(pointSize: 13, 264 287 weight: .semibold) 288 + // "Mouse Only" was the way to disable global keyboard capture; with 289 + // local capture (click menubar piano → type to play, no Accessibility 290 + // needed) that mode is handled implicitly by simply not triggering 291 + // global capture. The popover now just picks the keymap layout. 265 292 let modeSpecs: [(label: String, symbol: String)] = [ 266 - ("Mouse Only", "cursorarrow"), 267 293 ("Notepat.com", "keyboard"), 268 294 ("Ableton MIDI Keys", "pianokeys"), 269 295 ] ··· 334 360 // popover stays small even though the full list is much taller. 335 361 // Hidden in MIDI mode — the DAW picks instruments there, so this 336 362 // local picker would just be misleading dead UI. 337 - instrumentLabel = NSTextField(labelWithString: "Instrument") 363 + // 364 + // Title row: "Instrument:" left, "078 Whistle" right (greyed). The 365 + // readout used to live under the grid; promoting it to the title 366 + // row keeps the eye on a single line while browsing cells. 367 + instrumentLabel = NSTextField(labelWithString: "Instrument:") 338 368 instrumentLabel.font = NSFont.systemFont(ofSize: 11, weight: .semibold) 339 369 instrumentLabel.textColor = .labelColor 340 - stack.addArrangedSubview(instrumentLabel) 370 + instrumentReadout = NSTextField(labelWithString: "") 371 + instrumentReadout.font = NSFont.monospacedSystemFont(ofSize: 11, weight: .regular) 372 + instrumentReadout.textColor = .secondaryLabelColor 373 + instrumentReadout.lineBreakMode = .byTruncatingTail 374 + instrumentTitleRow = NSStackView(views: [instrumentLabel, instrumentReadout]) 375 + instrumentTitleRow.orientation = .horizontal 376 + instrumentTitleRow.alignment = .firstBaseline 377 + instrumentTitleRow.spacing = 6 378 + // Hugging high on the label, low on the readout, so the readout 379 + // expands to fill the trailing space and truncates cleanly. 380 + instrumentLabel.setContentHuggingPriority(.defaultHigh, for: .horizontal) 381 + instrumentReadout.setContentHuggingPriority(.defaultLow, for: .horizontal) 382 + instrumentReadout.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) 383 + stack.addArrangedSubview(instrumentTitleRow) 341 384 342 385 instrumentList = InstrumentListView() 343 386 instrumentList.translatesAutoresizingMaskIntoConstraints = false ··· 352 395 stack.addArrangedSubview(instrumentList) 353 396 instrumentList.widthAnchor.constraint(equalToConstant: InstrumentListView.preferredWidth).isActive = true 354 397 instrumentList.heightAnchor.constraint(equalToConstant: InstrumentListView.preferredHeight).isActive = true 355 - 356 - // Readout for the selected program — "078 Whistle" — sits right 357 - // below the grid since the cells themselves only show numbers. 358 - instrumentReadout = NSTextField(labelWithString: "") 359 - instrumentReadout.font = NSFont.monospacedSystemFont(ofSize: 11, weight: .medium) 360 - instrumentReadout.textColor = .labelColor 361 - instrumentReadout.lineBreakMode = .byTruncatingTail 362 - stack.addArrangedSubview(instrumentReadout) 363 398 364 399 let bottomSeparator = makeSeparator() 365 400 stack.addArrangedSubview(bottomSeparator) ··· 501 536 func syncFromController() { 502 537 guard isViewLoaded, let n = menuBand else { return } 503 538 midiSwitch.state = n.midiMode ? .on : .off 539 + updateMuteButton(muted: n.muted) 504 540 octaveStepper.integerValue = n.octaveShift 505 541 updateOctaveLabel(n.octaveShift) 506 - let segIdx = inputModeSegment(typeMode: n.typeMode, keymap: n.keymap) 542 + let segIdx = inputModeSegment(keymap: n.keymap) 507 543 for (i, btn) in modeButtons.enumerated() { 508 544 btn.state = (i == segIdx) ? .on : .off 509 545 } ··· 512 548 updateSelfTestLabel(state: n.midiMode ? n.midiSelfTest : .unknown) 513 549 refreshCrashStatus() 514 550 refreshUpdateBanner() 515 - // Waveform: live only when local synth is the audible path. MIDI 516 - // mode silences the local mixer, so the line would be flat — hide 517 - // it instead of showing a misleading dead waveform. 518 - waveformView.isHidden = n.midiMode 551 + // Waveform: live only when local synth is the audible path. 552 + // Stays in the layout when MIDI mode is on; the palette 553 + // visibility helper greys it out instead of collapsing. 554 + waveformView.isHidden = false 519 555 waveformView.isLive = !n.midiMode 520 - // Instrument palette: only meaningful when the local synth is the 521 - // audio path. In MIDI mode the DAW chooses the instrument, so the 522 - // local picker is hidden along with its label, separator, and 523 - // selected-program readout. 556 + // Instrument palette: stays in the layout but greys out when 557 + // MIDI mode owns the audio path. Same physical width either way. 524 558 applyInstrumentPaletteVisibility(midiMode: n.midiMode) 525 559 // Wire up live updates so the label reflects loopback results as 526 560 // they land (test runs ~50ms after toggle-on; result settles a moment ··· 675 709 676 710 // MARK: - Actions 677 711 712 + @objc private func muteButtonClicked(_ sender: NSButton) { 713 + menuBand?.toggleMuted() 714 + if let m = menuBand { 715 + updateMuteButton(muted: m.muted) 716 + // Tactile feedback so the icon flip feels confirmed. "Tink" matches 717 + // the MIDI switch's feedback so the two toggles read as a pair. 718 + NSSound(named: NSSound.Name("Tink"))?.play() 719 + } 720 + } 721 + 722 + private func updateMuteButton(muted: Bool) { 723 + guard let btn = muteButton else { return } 724 + let symbol = muted ? "speaker.slash.fill" : "speaker.fill" 725 + let cfg = NSImage.SymbolConfiguration(pointSize: 13, weight: .semibold) 726 + btn.image = NSImage(systemSymbolName: symbol, 727 + accessibilityDescription: muted ? "Unmute" : "Mute")? 728 + .withSymbolConfiguration(cfg) 729 + btn.contentTintColor = muted ? .systemRed : .secondaryLabelColor 730 + btn.toolTip = muted ? "Unmute local synth" : "Mute local synth" 731 + } 732 + 678 733 @objc private func midiSwitchToggled(_ sender: NSSwitch) { 679 734 // Just toggle — don't run the heavy syncFromController. The switch 680 735 // already shows the user's intent; the loopback test (skipped on ··· 684 739 // Tactile feedback — short system tick so the flip feels mechanical 685 740 // even though it's a software switch. 686 741 NSSound(named: NSSound.Name("Tink"))?.play() 687 - // Waveform + instrument palette follow the new MIDI state directly 688 - // so they appear / disappear the moment the user flips the switch. 742 + // Waveform + instrument palette grey out (don't hide) when MIDI 743 + // mode is on — the DAW chooses the instrument and the audio path, 744 + // so the local controls aren't useful, but keeping them in place 745 + // means the popover's geometry stays stable and the user can see 746 + // exactly what's available without MIDI engaged. 689 747 if let m = menuBand { 690 - waveformView.animator().isHidden = m.midiMode 691 748 waveformView.isLive = !m.midiMode 692 - applyInstrumentPaletteVisibility(midiMode: m.midiMode, animated: true) 749 + applyInstrumentPaletteVisibility(midiMode: m.midiMode) 693 750 } 694 751 } 695 752 696 753 private func applyInstrumentPaletteVisibility(midiMode: Bool, animated: Bool = false) { 697 - // Compute the target popover size with the palette toggled. Hide 698 - // first inside a layout pass so `fittingSize` reflects the post- 699 - // collapse stack — without this the new size would equal the old. 700 - let setHidden = { 701 - self.instrumentSeparator.isHidden = midiMode 702 - self.instrumentLabel.isHidden = midiMode 703 - self.instrumentList.isHidden = midiMode 704 - self.instrumentReadout.isHidden = midiMode 705 - } 706 - if !animated { 707 - setHidden() 708 - view.needsLayout = true 709 - view.layoutSubtreeIfNeeded() 710 - let fitting = view.fittingSize 711 - preferredContentSize = NSSize(width: fitting.width, 712 - height: fitting.height) 713 - return 714 - } 715 - // Animated path: NSStackView collapses arranged subviews when their 716 - // `isHidden` flips inside an animation group, and the popover's 717 - // backing window is an NSWindow that animates frame changes via its 718 - // animator proxy. We hide the stack rows inside the animation 719 - // context (so they fade with the resize) and slide the window's 720 - // height in lockstep. `preferredContentSize` is committed at the 721 - // end so AppKit's bookkeeping matches the final geometry. 722 - setHidden() 723 - view.needsLayout = true 724 - view.layoutSubtreeIfNeeded() 725 - let fitting = view.fittingSize 726 - let newSize = NSSize(width: fitting.width, height: fitting.height) 727 - guard let window = view.window else { 728 - preferredContentSize = newSize 729 - return 730 - } 731 - // NSPopover anchors its arrow to the menubar; on .minY edge the 732 - // popover hangs below, so its window's origin.y stays fixed at the 733 - // top while only the height changes downward. Resize from the top 734 - // by adjusting origin.y to keep the arrow attached. 735 - var frame = window.frame 736 - let dh = newSize.height - frame.height 737 - frame.origin.y -= dh 738 - frame.size.height = newSize.height 739 - frame.size.width = newSize.width 740 - NSAnimationContext.runAnimationGroup { ctx in 741 - ctx.duration = 0.18 742 - ctx.timingFunction = CAMediaTimingFunction(name: .easeOut) 743 - ctx.allowsImplicitAnimation = true 744 - window.animator().setFrame(frame, display: true) 745 - } 746 - preferredContentSize = newSize 754 + // Greyed-out, not hidden: keep the rows in place so the popover 755 + // doesn't reflow when MIDI flips. We dim alpha rather than touching 756 + // `isHidden`, which would collapse the row and resize the popover. 757 + let dimmed: CGFloat = midiMode ? 0.35 : 1.0 758 + instrumentSeparator.alphaValue = dimmed 759 + instrumentTitleRow.alphaValue = dimmed 760 + instrumentList.alphaValue = dimmed 761 + // Waveform stays visible (no longer collapses), just stops 762 + // ingesting samples — `isLive = false` (set by the caller) means 763 + // the bars freeze at their last value rather than going dark. To 764 + // convey "this is inactive," we fade it with the same alpha as 765 + // the palette. `_ = animated` keeps the parameter signature 766 + // compatible with existing call sites. 767 + waveformView.alphaValue = dimmed 768 + _ = animated 747 769 } 748 770 749 - /// 0 = Pointer, 1 = Notepat, 2 = Ableton. Matches the segmented control 750 - /// in `loadView()`. 751 - private func inputModeSegment(typeMode: Bool, keymap: Keymap) -> Int { 752 - if !typeMode { return 0 } 753 - return keymap == .ableton ? 2 : 1 771 + /// 0 = Notepat, 1 = Ableton. Matches the vertical button stack in 772 + /// `loadView()` after the "Mouse Only" option was retired. 773 + private func inputModeSegment(keymap: Keymap) -> Int { 774 + return keymap == .ableton ? 1 : 0 754 775 } 755 776 756 777 @objc private func modeButtonClicked(_ sender: NSButton) { ··· 758 779 // Manual radio behaviour: only the clicked button stays .on. 759 780 for btn in modeButtons { btn.state = (btn == sender) ? .on : .off } 760 781 switch sender.tag { 761 - case 0: // Mouse Only 762 - if m.typeMode { m.toggleTypeMode() } 763 - case 1: // Notepat.com 782 + case 0: // Notepat.com 764 783 m.keymap = .notepat 765 784 if !m.typeMode { m.toggleTypeMode() } 766 - case 2: // Ableton MIDI Keys 785 + case 1: // Ableton MIDI Keys 767 786 m.keymap = .ableton 768 787 if !m.typeMode { m.toggleTypeMode() } 769 788 default: break
+5 -10
slab/menuband/Sources/MenuBand/WaveformView.swift
··· 185 185 smoothedPeak = max(0.05, smoothedPeak * 0.92 + framePeak * 0.08) 186 186 } 187 187 let gain = 0.95 / smoothedPeak 188 - // Per-bar ballistics — fast attack (snap up on transients), slow 189 - // release (bars trail off ~100 ms instead of popping to zero). 190 - // Without this the bars look snappy/jumpy at any framerate; with it 191 - // they read as a smooth animation even at 60 Hz. 192 - let attack: Float = 0.55 193 - let release: Float = 0.18 188 + // Direct readout — no inter-frame ballistics. The user prefers 189 + // raw peak amplitude over smoothed visuals; bars track the 190 + // analyzed level exactly so transients land within one display 191 + // frame of the audio that triggered them. 194 192 for b in 0..<n { 195 - let target = min(1.0, levels[b] * gain) 196 - let cur = displayLevels[b] 197 - let k = target > cur ? attack : release 198 - displayLevels[b] = cur + (target - cur) * k 193 + displayLevels[b] = min(1.0, levels[b] * gain) 199 194 } 200 195 } 201 196
+13 -2
slab/menuband/install.sh
··· 136 136 ensure_self_signed_identity 137 137 SIGN_ID="${SELF_SIGN_CN}" 138 138 fi 139 + # Strip any custom-icon detritus before signing. The IconTinter writes an 140 + # `Icon\r` file with a resource fork to give the bundle an accent-colored 141 + # Finder icon at runtime; that resource fork makes codesign refuse the 142 + # bundle ("resource fork, Finder information, or similar detritus not 143 + # allowed"), silently leaving an ad-hoc signature behind. 144 + rm -f "${APP_DIR}/Icon"$'\r' 145 + xattr -cr "${APP_DIR}" 2>/dev/null || true 146 + 139 147 say "signing with: ${SIGN_ID}" 140 148 # --options runtime + --entitlements + --timestamp are all required for 141 149 # Apple's notary service to accept the bundle. Without them notarytool 142 150 # rejects with "The signature does not include a secure timestamp" or 143 151 # "The executable does not have the hardened runtime enabled." 144 152 ENTITLEMENTS="${SCRIPT_DIR}/MenuBand.entitlements" 145 - codesign --force --deep --sign "${SIGN_ID}" \ 153 + if ! codesign --force --deep --sign "${SIGN_ID}" \ 146 154 --identifier computer.aestheticcomputer.menuband \ 147 155 --options runtime \ 148 156 --entitlements "${ENTITLEMENTS}" \ 149 157 --timestamp \ 150 - "${APP_DIR}" >/dev/null 2>&1 || warn "codesign failed" 158 + "${APP_DIR}" 2>&1; then 159 + warn "codesign failed — bundle is not signed with hardened runtime" 160 + exit 1 161 + fi 151 162 ok "signed" 152 163 153 164 say "writing launchd plist → ${PLIST_PATH}"