Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

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

slab/iphone-mirror-tap: add MirrorTap menubar app

Tiny notarized .app that taps iPhone Mirroring's Capture button when
Dragonframe fires its SHOOT event, so one Enter press in DF captures
both cameras for stop-motion shoots. URL scheme (mirrortap://capture)
is invoked from a generated DF Action Script, avoiding any synthetic
keystrokes.

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

+1562
+3
slab/iphone-mirror-tap/.gitignore
··· 1 + build/ 2 + MirrorTap.app/ 3 + *.zip
+43
slab/iphone-mirror-tap/Info.plist
··· 1 + <?xml version="1.0" encoding="UTF-8"?> 2 + <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> 3 + <plist version="1.0"> 4 + <dict> 5 + <key>CFBundleDevelopmentRegion</key> 6 + <string>en</string> 7 + <key>CFBundleExecutable</key> 8 + <string>MirrorTap</string> 9 + <key>CFBundleIdentifier</key> 10 + <string>computer.aesthetic.mirrortap</string> 11 + <key>CFBundleInfoDictionaryVersion</key> 12 + <string>6.0</string> 13 + <key>CFBundleName</key> 14 + <string>MirrorTap</string> 15 + <key>CFBundleDisplayName</key> 16 + <string>MirrorTap</string> 17 + <key>CFBundlePackageType</key> 18 + <string>APPL</string> 19 + <key>CFBundleShortVersionString</key> 20 + <string>0.1</string> 21 + <key>CFBundleVersion</key> 22 + <string>1</string> 23 + <key>LSMinimumSystemVersion</key> 24 + <string>13.0</string> 25 + <key>LSUIElement</key> 26 + <true/> 27 + <key>NSHighResolutionCapable</key> 28 + <true/> 29 + <key>NSAppleEventsUsageDescription</key> 30 + <string>MirrorTap needs to interact with iPhone Mirroring to send a tap.</string> 31 + <key>CFBundleURLTypes</key> 32 + <array> 33 + <dict> 34 + <key>CFBundleURLName</key> 35 + <string>computer.aesthetic.mirrortap</string> 36 + <key>CFBundleURLSchemes</key> 37 + <array> 38 + <string>mirrortap</string> 39 + </array> 40 + </dict> 41 + </array> 42 + </dict> 43 + </plist>
+8
slab/iphone-mirror-tap/MirrorTap.entitlements
··· 1 + <?xml version="1.0" encoding="UTF-8"?> 2 + <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> 3 + <plist version="1.0"> 4 + <dict> 5 + <key>com.apple.security.app-sandbox</key> 6 + <false/> 7 + </dict> 8 + </plist>
+125
slab/iphone-mirror-tap/build.sh
··· 1 + #!/usr/bin/env bash 2 + # build.sh — compile, bundle, sign, notarize, staple → MirrorTap.app 3 + # 4 + # Pulls the Developer ID cert + notarization creds from the vault at 5 + # aesthetic-computer-vault/ac-electron/.env. The .p12 is imported into a 6 + # build-only keychain on first run so we don't pollute the login keychain. 7 + # 8 + # Usage: 9 + # ./build.sh # build + sign + notarize + staple 10 + # ./build.sh --no-notary # build + sign only (faster; opens with right-click) 11 + # 12 + # Output: MirrorTap.app in this directory. 13 + 14 + set -euo pipefail 15 + 16 + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 17 + cd "$SCRIPT_DIR" 18 + 19 + VAULT_ENV="$(cd "$SCRIPT_DIR/../../aesthetic-computer-vault/ac-electron" && pwd)/.env" 20 + NOTARIZE=1 21 + [[ "${1:-}" == "--no-notary" ]] && NOTARIZE=0 22 + 23 + if [[ ! -f "$VAULT_ENV" ]]; then 24 + echo "vault env missing at $VAULT_ENV" >&2 25 + exit 1 26 + fi 27 + # shellcheck disable=SC1090 28 + source "$VAULT_ENV" 29 + : "${CSC_KEY_PASSWORD:?missing CSC_KEY_PASSWORD}" 30 + : "${APPLE_ID:?missing APPLE_ID}" 31 + : "${APPLE_APP_SPECIFIC_PASSWORD:?missing APPLE_APP_SPECIFIC_PASSWORD}" 32 + : "${APPLE_TEAM_ID:?missing APPLE_TEAM_ID}" 33 + 34 + P12="$(cd "$(dirname "$VAULT_ENV")" && pwd)/mac-developer-id.p12" 35 + [[ -f "$P12" ]] || { echo "missing $P12" >&2; exit 1; } 36 + 37 + APP_NAME="MirrorTap" 38 + APP_BUNDLE="$SCRIPT_DIR/$APP_NAME.app" 39 + BUILD_DIR="$SCRIPT_DIR/build" 40 + KEYCHAIN="$BUILD_DIR/build.keychain" 41 + KEYCHAIN_PASS="mirrortap-build" 42 + 43 + rm -rf "$BUILD_DIR" "$APP_BUNDLE" 44 + mkdir -p "$BUILD_DIR" "$APP_BUNDLE/Contents/MacOS" "$APP_BUNDLE/Contents/Resources" 45 + 46 + echo "==> compile" 47 + swiftc -O -o "$APP_BUNDLE/Contents/MacOS/$APP_NAME" tap.swift \ 48 + -framework Cocoa -framework ApplicationServices -framework Carbon 49 + 50 + cp Info.plist "$APP_BUNDLE/Contents/Info.plist" 51 + 52 + echo "==> import cert into ephemeral keychain" 53 + # Capture the original search list before we touch anything so we can 54 + # always restore it. If a stale build keychain is registered, drop it. 55 + ORIG_SEARCH=$(security list-keychains -d user \ 56 + | sed -e 's/"//g' -e 's/^[[:space:]]*//' \ 57 + | grep -v 'iphone-mirror-tap/build/build.keychain' || true) 58 + security delete-keychain "$KEYCHAIN" 2>/dev/null || true 59 + 60 + security create-keychain -p "$KEYCHAIN_PASS" "$KEYCHAIN" 61 + security set-keychain-settings -lut 21600 "$KEYCHAIN" 62 + security unlock-keychain -p "$KEYCHAIN_PASS" "$KEYCHAIN" 63 + security import "$P12" -k "$KEYCHAIN" -P "$CSC_KEY_PASSWORD" -A >/dev/null 64 + security set-key-partition-list -S apple-tool:,apple:,codesign: \ 65 + -s -k "$KEYCHAIN_PASS" "$KEYCHAIN" >/dev/null 66 + 67 + # The .p12 holds only the leaf — fetch Apple's Developer ID G2 intermediate 68 + # so the chain validates. Cached after the first build. 69 + G2_CA="$BUILD_DIR/DeveloperIDG2CA.cer" 70 + if [[ ! -f "$G2_CA" ]]; then 71 + curl -fsSL -o "$G2_CA" \ 72 + https://www.apple.com/certificateauthority/DeveloperIDG2CA.cer 73 + fi 74 + security import "$G2_CA" -k "$KEYCHAIN" >/dev/null 75 + 76 + # Prepend the build keychain to the search list so codesign can find it. 77 + security list-keychains -d user -s "$KEYCHAIN" $ORIG_SEARCH 78 + 79 + IDENTITY=$(security find-identity -p codesigning -v "$KEYCHAIN" \ 80 + | grep -E 'Developer ID Application' | head -1 \ 81 + | sed -E 's/.*"(Developer ID Application: [^"]+)".*/\1/') 82 + [[ -n "$IDENTITY" ]] || { echo "no Developer ID Application identity found" >&2; exit 1; } 83 + echo " identity: $IDENTITY" 84 + 85 + echo "==> codesign (hardened runtime)" 86 + codesign --force --options runtime --timestamp \ 87 + --entitlements MirrorTap.entitlements \ 88 + --sign "$IDENTITY" --keychain "$KEYCHAIN" \ 89 + "$APP_BUNDLE/Contents/MacOS/$APP_NAME" 90 + codesign --force --options runtime --timestamp \ 91 + --entitlements MirrorTap.entitlements \ 92 + --sign "$IDENTITY" --keychain "$KEYCHAIN" \ 93 + "$APP_BUNDLE" 94 + 95 + echo "==> verify signature" 96 + codesign --verify --deep --strict --verbose=2 "$APP_BUNDLE" 97 + 98 + if [[ "$NOTARIZE" == "1" ]]; then 99 + ZIP="$BUILD_DIR/$APP_NAME.zip" 100 + echo "==> zip for notarization" 101 + /usr/bin/ditto -c -k --keepParent "$APP_BUNDLE" "$ZIP" 102 + 103 + echo "==> submit to notary (this can take a minute or three)" 104 + xcrun notarytool submit "$ZIP" \ 105 + --apple-id "$APPLE_ID" \ 106 + --team-id "$APPLE_TEAM_ID" \ 107 + --password "$APPLE_APP_SPECIFIC_PASSWORD" \ 108 + --wait 109 + 110 + echo "==> staple" 111 + xcrun stapler staple "$APP_BUNDLE" 112 + spctl -a -vv "$APP_BUNDLE" || true 113 + fi 114 + 115 + # Restore original keychain search list (don't leave the build keychain pinned). 116 + security list-keychains -d user -s $ORIG_SEARCH 117 + security delete-keychain "$KEYCHAIN" 2>/dev/null || true 118 + 119 + echo 120 + echo "✓ built $APP_BUNDLE" 121 + if [[ "$NOTARIZE" == "1" ]]; then 122 + echo " (signed + notarized — Gatekeeper will allow on any Mac)" 123 + else 124 + echo " (signed only — first launch needs right-click → Open)" 125 + fi
+48
slab/iphone-mirror-tap/shoot.sh
··· 1 + #!/usr/bin/env bash 2 + # Five screenshots of the iPhone Mirroring window, five seconds apart. 3 + # Window position+size is re-queried every iteration so it tracks any 4 + # move or resize between shots. 5 + # 6 + # Heads up: iPhone Mirroring may render as solid black in captures 7 + # because of Continuity privacy. If that's what you see, take the 8 + # screenshot from the iPhone itself instead (it's only used here to 9 + # locate the Capture button visually for one-time calibration). 10 + # 11 + # Usage: ./shoot.sh [output-dir] 12 + # Default output: ~/Desktop/iphone-mirror-shots 13 + 14 + set -e 15 + OUT="${1:-$HOME/Desktop/iphone-mirror-shots}" 16 + mkdir -p "$OUT" 17 + 18 + bounds() { 19 + osascript <<'OSA' 20 + tell application "System Events" 21 + if not (exists process "iPhone Mirroring") then return "missing" 22 + tell process "iPhone Mirroring" 23 + if (count of windows) is 0 then return "missing" 24 + set p to position of window 1 25 + set s to size of window 1 26 + set AppleScript's text item delimiters to "," 27 + return ((p & s) as text) 28 + end tell 29 + end tell 30 + OSA 31 + } 32 + 33 + for i in 1 2 3 4 5; do 34 + b=$(bounds) 35 + if [ "$b" = "missing" ]; then 36 + echo "[$i] iPhone Mirroring isn't running. Open it and rerun." 37 + exit 1 38 + fi 39 + ts=$(date +%H%M%S) 40 + file="$OUT/shot-$i-$ts.png" 41 + echo "[$i] bounds=$b -> $file" 42 + screencapture -R "$b" -x "$file" 43 + [ "$i" -lt 5 ] && sleep 5 44 + done 45 + 46 + echo 47 + echo "Done. Open: $OUT" 48 + open "$OUT"
+253
slab/iphone-mirror-tap/tap.swift
··· 1 + // tap.swift — MirrorTap menubar app. 2 + // 3 + // Posts a synthetic click on the iPhone Mirroring window's Capture button 4 + // in response to two triggers: 5 + // 6 + // 1. Dragonframe Action Script (preferred). DF runs a shell script on 7 + // every event (Preferences → Advanced → Action Script). On SHOOT 8 + // events the script calls `open mirrortap://capture`, which routes 9 + // to this running app via the registered URL scheme. Net result: 10 + // pressing Enter in Dragonframe captures BOTH the DF camera and the 11 + // mirrored iPhone, with no fake keystrokes. 12 + // 13 + // 2. The menu-bar button (manual / fallback). Left-click taps the 14 + // iPhone immediately. Right-click opens the menu. 15 + // 16 + // First launch on a clean Mac: macOS prompts for Accessibility (TCC). 17 + // That's needed both for AX queries on iPhone Mirroring's window and for 18 + // posting the synthetic mouse click. Grant it once. 19 + // 20 + // Calibration: tapX/tapY are window-relative fractions for where to 21 + // click inside the iPhone Mirroring window. Default = bottom center. 22 + // Tweak and rebuild via ./build.sh. 23 + 24 + import Cocoa 25 + import ApplicationServices 26 + import Carbon.HIToolbox 27 + 28 + // ---- config -------------------------------------------------------------- 29 + let tapX: CGFloat = 0.50 30 + let tapY: CGFloat = 0.90 31 + // -------------------------------------------------------------------------- 32 + 33 + func runningApp(named name: String) -> NSRunningApplication? { 34 + NSWorkspace.shared.runningApplications.first { $0.localizedName == name } 35 + } 36 + 37 + func iPhoneMirroringFrame() -> CGRect? { 38 + guard let pid = runningApp(named: "iPhone Mirroring")?.processIdentifier 39 + else { return nil } 40 + let app = AXUIElementCreateApplication(pid) 41 + var windowsRef: CFTypeRef? 42 + guard AXUIElementCopyAttributeValue(app, kAXWindowsAttribute as CFString, 43 + &windowsRef) == .success, 44 + let windows = windowsRef as? [AXUIElement], 45 + let win = windows.first else { return nil } 46 + var posRef: CFTypeRef? 47 + var sizeRef: CFTypeRef? 48 + guard AXUIElementCopyAttributeValue(win, kAXPositionAttribute as CFString, 49 + &posRef) == .success, 50 + AXUIElementCopyAttributeValue(win, kAXSizeAttribute as CFString, 51 + &sizeRef) == .success 52 + else { return nil } 53 + var pos = CGPoint.zero, size = CGSize.zero 54 + AXValueGetValue(posRef as! AXValue, .cgPoint, &pos) 55 + AXValueGetValue(sizeRef as! AXValue, .cgSize, &size) 56 + return CGRect(origin: pos, size: size) 57 + } 58 + 59 + func clickAt(_ point: CGPoint) { 60 + let saved = CGEvent(source: nil)?.location 61 + let down = CGEvent(mouseEventSource: nil, mouseType: .leftMouseDown, 62 + mouseCursorPosition: point, mouseButton: .left) 63 + let up = CGEvent(mouseEventSource: nil, mouseType: .leftMouseUp, 64 + mouseCursorPosition: point, mouseButton: .left) 65 + down?.post(tap: .cghidEventTap) 66 + up?.post(tap: .cghidEventTap) 67 + if let saved = saved { CGWarpMouseCursorPosition(saved) } 68 + } 69 + 70 + func tapPhone() { 71 + guard let frame = iPhoneMirroringFrame() else { 72 + NSLog("MirrorTap: iPhone Mirroring window not found.") 73 + return 74 + } 75 + let target = CGPoint(x: frame.minX + frame.width * tapX, 76 + y: frame.minY + frame.height * tapY) 77 + let prev = NSWorkspace.shared.frontmostApplication 78 + runningApp(named: "iPhone Mirroring")?.activate(options: []) 79 + usleep(80_000) 80 + clickAt(target) 81 + usleep(40_000) 82 + prev?.activate(options: []) 83 + } 84 + 85 + // Action script template installed into ~/Library/Application Support 86 + // and pointed at from Dragonframe Preferences → Advanced → Action Script. 87 + // Dragonframe passes 8 positional args; $4 is the event name. 88 + let actionScriptBody = """ 89 + #!/usr/bin/env bash 90 + # MirrorTap — Dragonframe action script. 91 + # Configured via Dragonframe Preferences → Advanced → Action Script. 92 + # 93 + # Args from Dragonframe: 94 + # $1 production $2 scene $3 take $4 event 95 + # $5 frame $6 exposure $7 expName $8 filename 96 + # 97 + # On SHOOT (the moment the user triggers capture) we ping MirrorTap via 98 + # its URL scheme, which makes the running app tap iPhone Mirroring. 99 + case "$4" in 100 + SHOOT) 101 + /usr/bin/open "mirrortap://capture" >/dev/null 2>&1 102 + ;; 103 + esac 104 + exit 0 105 + """ 106 + 107 + func actionScriptPath() -> String { 108 + let support = (NSSearchPathForDirectoriesInDomains( 109 + .applicationSupportDirectory, .userDomainMask, true).first ?? "") 110 + return support + "/MirrorTap/dragonframe-action.sh" 111 + } 112 + 113 + func installActionScript() -> String? { 114 + let path = actionScriptPath() 115 + let dir = (path as NSString).deletingLastPathComponent 116 + do { 117 + try FileManager.default.createDirectory(atPath: dir, 118 + withIntermediateDirectories: true) 119 + try actionScriptBody.write(toFile: path, atomically: true, encoding: .utf8) 120 + try FileManager.default.setAttributes( 121 + [.posixPermissions: NSNumber(value: 0o755)], ofItemAtPath: path) 122 + return path 123 + } catch { 124 + NSLog("MirrorTap: install failed — \(error)") 125 + return nil 126 + } 127 + } 128 + 129 + class AppDelegate: NSObject, NSApplicationDelegate { 130 + var statusItem: NSStatusItem! 131 + var menu: NSMenu! 132 + var idleImage: NSImage? 133 + var firingImage: NSImage? 134 + 135 + func applicationDidFinishLaunching(_ note: Notification) { 136 + let promptKey = kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String 137 + _ = AXIsProcessTrustedWithOptions([promptKey: true] as CFDictionary) 138 + 139 + idleImage = NSImage(systemSymbolName: "camera.aperture", 140 + accessibilityDescription: "MirrorTap idle") 141 + firingImage = NSImage(systemSymbolName: "circle.inset.filled", 142 + accessibilityDescription: "MirrorTap firing") 143 + 144 + statusItem = NSStatusBar.system.statusItem( 145 + withLength: NSStatusItem.variableLength) 146 + if let btn = statusItem.button { 147 + btn.image = idleImage 148 + btn.imagePosition = .imageOnly 149 + btn.target = self 150 + btn.action = #selector(handleClick(_:)) 151 + btn.sendAction(on: [.leftMouseUp, .rightMouseUp]) 152 + btn.toolTip = "MirrorTap — click to tap iPhone Mirroring" 153 + } 154 + 155 + menu = NSMenu() 156 + let header = NSMenuItem(title: "MirrorTap", action: nil, keyEquivalent: "") 157 + header.isEnabled = false 158 + menu.addItem(header) 159 + menu.addItem(NSMenuItem.separator()) 160 + 161 + let tapItem = NSMenuItem(title: "Tap iPhone Now", 162 + action: #selector(tapFromMenu), 163 + keyEquivalent: "") 164 + tapItem.target = self 165 + menu.addItem(tapItem) 166 + 167 + menu.addItem(NSMenuItem.separator()) 168 + let installItem = NSMenuItem(title: "Install Dragonframe Hook…", 169 + action: #selector(installAndReveal), 170 + keyEquivalent: "") 171 + installItem.target = self 172 + menu.addItem(installItem) 173 + 174 + menu.addItem(NSMenuItem.separator()) 175 + menu.addItem(NSMenuItem(title: "Quit MirrorTap", 176 + action: #selector(NSApplication.terminate(_:)), 177 + keyEquivalent: "q")) 178 + } 179 + 180 + // URL scheme handler — Dragonframe's action script calls this via 181 + // `open mirrortap://capture` to ask us to tap iPhone Mirroring. 182 + func application(_ app: NSApplication, open urls: [URL]) { 183 + for url in urls where url.scheme == "mirrortap" { 184 + let host = url.host?.lowercased() ?? url.path.replacingOccurrences( 185 + of: "/", with: "") 186 + switch host { 187 + case "capture", "tap": 188 + flashIcon() 189 + tapPhone() 190 + default: 191 + NSLog("MirrorTap: unknown URL \(url)") 192 + } 193 + } 194 + } 195 + 196 + @objc func handleClick(_ sender: Any?) { 197 + let event = NSApp.currentEvent 198 + let isRight = event?.type == .rightMouseUp 199 + let isCtrl = event?.modifierFlags.contains(.control) ?? false 200 + if isRight || isCtrl { showMenu(); return } 201 + flashIcon() 202 + tapPhone() 203 + } 204 + 205 + func showMenu() { 206 + statusItem.menu = menu 207 + statusItem.button?.performClick(nil) 208 + DispatchQueue.main.async { self.statusItem.menu = nil } 209 + } 210 + 211 + func flashIcon() { 212 + statusItem.button?.image = firingImage 213 + DispatchQueue.main.asyncAfter(deadline: .now() + 0.18) { [weak self] in 214 + self?.statusItem.button?.image = self?.idleImage 215 + } 216 + } 217 + 218 + @objc func tapFromMenu() { flashIcon(); tapPhone() } 219 + 220 + @objc func installAndReveal() { 221 + guard let path = installActionScript() else { 222 + let a = NSAlert() 223 + a.messageText = "Couldn't write the action script." 224 + a.informativeText = "Check ~/Library/Application Support permissions." 225 + a.runModal() 226 + return 227 + } 228 + NSPasteboard.general.clearContents() 229 + NSPasteboard.general.setString(path, forType: .string) 230 + NSWorkspace.shared.activateFileViewerSelecting( 231 + [URL(fileURLWithPath: path)]) 232 + 233 + NSApp.activate(ignoringOtherApps: true) 234 + let a = NSAlert() 235 + a.messageText = "Dragonframe hook installed" 236 + a.informativeText = """ 237 + Path (also copied to clipboard): 238 + \(path) 239 + 240 + Next: in Dragonframe → Preferences → Advanced → Action Script, 241 + choose this file. From then on, every SHOOT in Dragonframe 242 + will also tap iPhone Mirroring. 243 + """ 244 + a.addButton(withTitle: "OK") 245 + a.runModal() 246 + } 247 + } 248 + 249 + let app = NSApplication.shared 250 + let delegate = AppDelegate() 251 + app.delegate = delegate 252 + app.setActivationPolicy(.accessory) 253 + app.run()
+432
slab/menuband/Sources/MenuBand/MenuBandController.swift
··· 1 + import AppKit 2 + import ApplicationServices 3 + import CoreGraphics 4 + 5 + final class MenuBandController { 6 + private let midi = MenuBandMIDI() 7 + private let synth = MenuBandSynth() 8 + private var keyTap: KeyEventTap? 9 + private var heldNotes: [UInt16: UInt8] = [:] 10 + private let heldLock = NSLock() 11 + 12 + private let midiModeKey = "notepat.midiMode" 13 + private let typeModeKey = "notepat.typeMode" 14 + private let octaveShiftKey = "notepat.octaveShift" 15 + private let melodicProgramKey = "notepat.melodicProgram" 16 + private let keymapKey = "notepat.keymap" 17 + 18 + // Visual state — accessed only on the main thread. 19 + private(set) var litNotes: Set<UInt8> = [] 20 + private var litDownAt: [UInt8: CFTimeInterval] = [:] 21 + private let minVisibleSeconds: CFTimeInterval = 0.08 22 + 23 + var onChange: (() -> Void)? 24 + var onLitChanged: (() -> Void)? 25 + 26 + var midiMode: Bool { 27 + UserDefaults.standard.bool(forKey: midiModeKey) 28 + } 29 + 30 + var typeMode: Bool { 31 + UserDefaults.standard.bool(forKey: typeModeKey) 32 + } 33 + 34 + var keymap: Keymap { 35 + get { 36 + let raw = UserDefaults.standard.string(forKey: keymapKey) ?? "" 37 + return Keymap(rawValue: raw) ?? .notepat 38 + } 39 + set { 40 + UserDefaults.standard.set(newValue.rawValue, forKey: keymapKey) 41 + onChange?() 42 + } 43 + } 44 + 45 + var octaveShift: Int { 46 + get { UserDefaults.standard.integer(forKey: octaveShiftKey) } 47 + set { 48 + UserDefaults.standard.set(newValue, forKey: octaveShiftKey) 49 + releaseAllHeldNotes() 50 + onChange?() 51 + } 52 + } 53 + 54 + var melodicProgram: UInt8 { 55 + let raw = UserDefaults.standard.integer(forKey: melodicProgramKey) 56 + return UInt8(max(0, min(127, raw))) 57 + } 58 + 59 + func setMelodicProgram(_ program: UInt8) { 60 + UserDefaults.standard.set(Int(program), forKey: melodicProgramKey) 61 + synth.setMelodicProgram(program) 62 + onChange?() 63 + } 64 + 65 + 66 + func bootstrap() { 67 + // Built-in synth is always live. TYPE mode and MIDI mode are now 68 + // independent toggles: TYPE controls global keyboard capture + key 69 + // letter overlays; MIDI controls the virtual MIDI port output to 70 + // external DAWs. 71 + synth.start() 72 + synth.setMelodicProgram(melodicProgram) 73 + if typeMode { enableTypeMode(promptForPermission: false) } 74 + if midiMode { enableMIDIMode() } 75 + } 76 + 77 + func toggleMIDIMode() { 78 + if midiMode { disableMIDIMode() } else { enableMIDIMode() } 79 + } 80 + 81 + func toggleTypeMode() { 82 + if typeMode { 83 + disableTypeMode(playFeedback: true) 84 + } else { 85 + enableTypeMode(promptForPermission: true, playFeedback: true) 86 + } 87 + } 88 + 89 + func shutdown() { 90 + disableTypeMode() 91 + disableMIDIMode() 92 + synth.stop() 93 + } 94 + 95 + // MARK: - MIDI virtual port (Ableton-facing output) 96 + 97 + private func enableMIDIMode() { 98 + midi.start() 99 + synth.panic() // DAW takes over from internal synth 100 + UserDefaults.standard.set(true, forKey: midiModeKey) 101 + onChange?() 102 + } 103 + 104 + private func disableMIDIMode() { 105 + midi.stop() 106 + UserDefaults.standard.set(false, forKey: midiModeKey) 107 + onChange?() 108 + } 109 + 110 + // MARK: - TYPE mode (global keyboard capture + letter overlays) 111 + 112 + private func enableTypeMode(promptForPermission: Bool, playFeedback: Bool = false) { 113 + if !ensureAccessibility(prompt: promptForPermission) { 114 + UserDefaults.standard.set(false, forKey: typeModeKey) 115 + onChange?() 116 + return 117 + } 118 + let tap = KeyEventTap { [weak self] keyCode, isDown, isRepeat, flags in 119 + return self?.handleKey(keyCode: keyCode, isDown: isDown, isRepeat: isRepeat, flags: flags) ?? false 120 + } 121 + if !tap.start() { 122 + NSLog("MenuBand: CGEventTap creation failed (likely missing Accessibility permission)") 123 + UserDefaults.standard.set(false, forKey: typeModeKey) 124 + onChange?() 125 + return 126 + } 127 + keyTap = tap 128 + UserDefaults.standard.set(true, forKey: typeModeKey) 129 + installClickAwayMonitor() 130 + if playFeedback { 131 + NSSound(named: NSSound.Name("Submarine"))?.play() 132 + } 133 + onChange?() 134 + } 135 + 136 + private func disableTypeMode(playFeedback: Bool = false) { 137 + removeClickAwayMonitor() 138 + keyTap?.stop() 139 + keyTap = nil 140 + releaseAllHeldNotes() 141 + UserDefaults.standard.set(false, forKey: typeModeKey) 142 + if playFeedback { 143 + NSSound(named: NSSound.Name("Pop"))?.play() 144 + } 145 + onChange?() 146 + } 147 + 148 + // MARK: - Click-away auto-disable 149 + 150 + private var clickAwayMonitor: Any? 151 + private var appActivationObserver: Any? 152 + 153 + private func installClickAwayMonitor() { 154 + // Global mouse-down monitor: fires for events going to any OTHER app. 155 + // A click outside our menubar item exits TYPE mode so we don't keep 156 + // sinking keystrokes after the user moves on. 157 + if clickAwayMonitor == nil { 158 + clickAwayMonitor = NSEvent.addGlobalMonitorForEvents( 159 + matching: [.leftMouseDown, .rightMouseDown, .otherMouseDown] 160 + ) { [weak self] _ in 161 + DispatchQueue.main.async { self?.disableTypeMode() } 162 + } 163 + } 164 + // App-activation observer: belt-and-suspenders for cmd-tab and 165 + // dock/Spotlight-launched activations that NSEvent global monitors 166 + // sometimes miss. 167 + if appActivationObserver == nil { 168 + appActivationObserver = NSWorkspace.shared.notificationCenter.addObserver( 169 + forName: NSWorkspace.didActivateApplicationNotification, 170 + object: nil, queue: .main 171 + ) { [weak self] note in 172 + guard let info = note.userInfo, 173 + let app = info[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication, 174 + app.bundleIdentifier != Bundle.main.bundleIdentifier 175 + else { return } 176 + self?.disableTypeMode() 177 + } 178 + } 179 + } 180 + 181 + private func removeClickAwayMonitor() { 182 + if let m = clickAwayMonitor { NSEvent.removeMonitor(m) } 183 + clickAwayMonitor = nil 184 + if let o = appActivationObserver { 185 + NSWorkspace.shared.notificationCenter.removeObserver(o) 186 + } 187 + appActivationObserver = nil 188 + } 189 + 190 + // Drums (low GM percussion notes) route to GM channel 10 (index 9) so the 191 + // built-in DLS synth picks the standard drum kit instead of piano. 192 + @inline(__always) 193 + private func channel(for note: UInt8) -> UInt8 { 194 + note < UInt8(KeyboardIconRenderer.firstMidi) ? 9 : 0 195 + } 196 + 197 + // MARK: - Menubar tap / drag-to-play 198 + 199 + private var tapHeld: Set<UInt8> = [] // notes currently held by mouse drag (main thread) 200 + private var tapNoteChannel: [UInt8: UInt8] = [:] // active channel per held note 201 + private var melodicVoiceCursor: UInt8 = 0 // round-robin across 8 melodic channels 202 + 203 + @inline(__always) 204 + private func nextMelodicChannel() -> UInt8 { 205 + let c = melodicVoiceCursor 206 + melodicVoiceCursor = (melodicVoiceCursor &+ 1) & 0x07 207 + return c 208 + } 209 + 210 + /// Begin holding a note from a menubar mouse interaction. Velocity (0–127) 211 + /// and pan (0–127, 64=center) are passed in by the caller — the drag 212 + /// handler computes them from the cursor's relative position inside the 213 + /// hovered key, giving expressive control: y closer to vertical center = 214 + /// louder, x within key = stereo pan. 215 + func startTapNote(_ midiNote: UInt8, velocity: UInt8 = 100, pan: UInt8 = 64) { 216 + if tapHeld.contains(midiNote) { return } 217 + tapHeld.insert(midiNote) 218 + let isDrum = midiNote < UInt8(KeyboardIconRenderer.firstMidi) 219 + // Synth: rotate across 8 channels so rapid same-note taps overlap 220 + // (different channels = different voices, no stealing). MIDI: always 221 + // land on channel 1 (drums on 10) so an Ableton track listening on 222 + // a single channel actually receives every note. 223 + let synthCh: UInt8 = isDrum ? 9 : nextMelodicChannel() 224 + let midiCh: UInt8 = isDrum ? 9 : 0 225 + tapNoteChannel[midiNote] = synthCh 226 + midi.sendCC(10, value: pan, channel: midiCh) 227 + if !midiMode { synth.noteOn(midiNote, velocity: velocity, channel: synthCh) } 228 + midi.noteOn(midiNote, velocity: velocity, channel: midiCh) 229 + DispatchQueue.main.async { [weak self] in 230 + guard let self = self else { return } 231 + self.litDownAt[midiNote] = CACurrentMediaTime() 232 + if self.litNotes.insert(midiNote).inserted { 233 + self.onLitChanged?() 234 + } 235 + } 236 + } 237 + 238 + /// While dragging, update pan in real time as the cursor slides within 239 + /// the held note. Doesn't retrigger the note. 240 + func updateTapPan(_ midiNote: UInt8, pan: UInt8) { 241 + guard tapHeld.contains(midiNote) else { return } 242 + let midiCh: UInt8 = midiNote < UInt8(KeyboardIconRenderer.firstMidi) ? 9 : 0 243 + midi.sendCC(10, value: pan, channel: midiCh) 244 + } 245 + 246 + /// Release a note previously started with `startTapNote`. 247 + func stopTapNote(_ midiNote: UInt8) { 248 + guard tapHeld.contains(midiNote) else { return } 249 + tapHeld.remove(midiNote) 250 + let synthCh = tapNoteChannel.removeValue(forKey: midiNote) ?? channel(for: midiNote) 251 + let isDrum = midiNote < UInt8(KeyboardIconRenderer.firstMidi) 252 + let midiCh: UInt8 = isDrum ? 9 : 0 253 + // Drums are one-shot percussion: do NOT send synth.noteOff. Letting 254 + // the sample play through is what makes rapid taps overlap correctly 255 + // instead of cutting each other off. The MIDI port still sends noteOff 256 + // so external sequencers (Ableton drum racks) get a clean event pair. 257 + // Internal synth is silent in MIDI mode anyway. 258 + if !isDrum && !midiMode { 259 + synth.noteOff(midiNote, channel: synthCh) 260 + } 261 + midi.noteOff(midiNote, channel: midiCh) 262 + // Visual cleanup deferred so we don't pay image-render cost in the 263 + // mouse-up handler. 264 + DispatchQueue.main.async { [weak self] in 265 + guard let self = self else { return } 266 + let downAt = self.litDownAt.removeValue(forKey: midiNote) ?? 0 267 + let held = CACurrentMediaTime() - downAt 268 + let extinguish = { [weak self] in 269 + guard let self = self else { return } 270 + if self.litNotes.remove(midiNote) != nil { 271 + self.onLitChanged?() 272 + } 273 + } 274 + if held < self.minVisibleSeconds { 275 + DispatchQueue.main.asyncAfter(deadline: .now() + (self.minVisibleSeconds - held)) { 276 + extinguish() 277 + } 278 + } else { 279 + extinguish() 280 + } 281 + } 282 + } 283 + 284 + // MARK: - Permissions 285 + 286 + @discardableResult 287 + private func ensureAccessibility(prompt: Bool) -> Bool { 288 + // Always pass false here so the *system* modal doesn't fire. We show 289 + // our own alert that explains the situation and offers a "Reset grant" 290 + // escape hatch — the common cause of repeated prompts is a stale TCC 291 + // entry from a previous (ad-hoc / unsigned) build whose code signing 292 + // identity no longer matches the current bundle. 293 + let key = kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String 294 + let trusted = AXIsProcessTrustedWithOptions([key: false] as CFDictionary) 295 + if !trusted && prompt { 296 + DispatchQueue.main.async { self.presentAccessibilityAlert() } 297 + } 298 + return trusted 299 + } 300 + 301 + private func presentAccessibilityAlert() { 302 + let alert = NSAlert() 303 + alert.messageText = "MenuBand needs Accessibility access" 304 + alert.informativeText = """ 305 + To capture keystrokes globally, MenuBand must be enabled in \ 306 + System Settings → Privacy & Security → Accessibility. 307 + 308 + If you've already enabled it but this keeps prompting, the TCC \ 309 + entry is likely stale (left over from a previous build). Use \ 310 + "Reset & Re-prompt" to clear it and grant fresh. 311 + """ 312 + alert.alertStyle = .informational 313 + alert.addButton(withTitle: "Open Settings") 314 + alert.addButton(withTitle: "Reset & Re-prompt") 315 + alert.addButton(withTitle: "Cancel") 316 + NSApp.activate(ignoringOtherApps: true) 317 + let resp = alert.runModal() 318 + switch resp { 319 + case .alertFirstButtonReturn: 320 + if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility") { 321 + NSWorkspace.shared.open(url) 322 + } 323 + case .alertSecondButtonReturn: 324 + // Run `tccutil reset Accessibility <bundleID>` and then ask the 325 + // *system* to prompt fresh. This creates a new TCC entry tied to 326 + // our current code-signing identity. 327 + let bundleID = Bundle.main.bundleIdentifier ?? "computer.aestheticcomputer.menuband" 328 + let task = Process() 329 + task.launchPath = "/usr/bin/tccutil" 330 + task.arguments = ["reset", "Accessibility", bundleID] 331 + try? task.run() 332 + task.waitUntilExit() 333 + // Now request the system prompt explicitly. 334 + let key = kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String 335 + _ = AXIsProcessTrustedWithOptions([key: true] as CFDictionary) 336 + default: 337 + break 338 + } 339 + } 340 + 341 + // MARK: - Key handling (runs on KeyEventTap background thread) 342 + // Returns true to CONSUME the key event (sink it from the focused app); 343 + // false to let it pass through. 344 + 345 + private func handleKey(keyCode: UInt16, isDown: Bool, isRepeat: Bool, flags: CGEventFlags) -> Bool { 346 + // Escape exits TYPE mode (and consumes the keystroke). 347 + if isDown && keyCode == 53 { // kVK_Escape 348 + DispatchQueue.main.async { [weak self] in 349 + self?.disableTypeMode(playFeedback: true) 350 + } 351 + return true 352 + } 353 + // Modifier combos pass through so cmd-c, cmd-tab etc. work as usual. 354 + if flags.contains(.maskCommand) || flags.contains(.maskControl) || flags.contains(.maskAlternate) { return false } 355 + 356 + let shift = octaveShift // a single UserDefaults read; cheap 357 + 358 + if isDown { 359 + if isRepeat { return true } // consume repeats but don't retrigger 360 + guard let note = MenuBandLayout.midiNote(forKeyCode: keyCode, octaveShift: shift, keymap: keymap) else { 361 + // Unmapped key: still consume in TYPE mode so it doesn't leak 362 + // through to the focused app. 363 + return true 364 + } 365 + heldLock.lock() 366 + let prev = heldNotes[keyCode] 367 + heldNotes[keyCode] = note 368 + heldLock.unlock() 369 + if let prev = prev { 370 + if !midiMode { synth.noteOff(prev) } 371 + midi.noteOff(prev) 372 + } 373 + if !midiMode { synth.noteOn(note) } 374 + midi.noteOn(note) 375 + DispatchQueue.main.async { [weak self] in 376 + guard let self = self else { return } 377 + self.litDownAt[note] = CACurrentMediaTime() 378 + if self.litNotes.insert(note).inserted { 379 + self.onLitChanged?() 380 + } 381 + } 382 + return true 383 + } else { 384 + heldLock.lock() 385 + let note = heldNotes.removeValue(forKey: keyCode) 386 + heldLock.unlock() 387 + guard let releasedNote = note else { return true } // consume the up too 388 + if !midiMode { synth.noteOff(releasedNote) } 389 + midi.noteOff(releasedNote) 390 + DispatchQueue.main.async { [weak self] in 391 + guard let self = self else { return } 392 + let downAt = self.litDownAt.removeValue(forKey: releasedNote) ?? 0 393 + let held = CACurrentMediaTime() - downAt 394 + let extinguish = { [weak self] in 395 + guard let self = self else { return } 396 + if self.litNotes.remove(releasedNote) != nil { 397 + self.onLitChanged?() 398 + } 399 + } 400 + if held < self.minVisibleSeconds { 401 + DispatchQueue.main.asyncAfter(deadline: .now() + (self.minVisibleSeconds - held)) { 402 + extinguish() 403 + } 404 + } else { 405 + extinguish() 406 + } 407 + } 408 + return true 409 + } 410 + } 411 + 412 + private func releaseAllHeldNotes() { 413 + heldLock.lock() 414 + let snapshot = heldNotes 415 + heldNotes.removeAll() 416 + heldLock.unlock() 417 + for (_, note) in snapshot { 418 + synth.noteOff(note) 419 + midi.noteOff(note) 420 + } 421 + midi.sendAllNotesOff() 422 + synth.panic() 423 + DispatchQueue.main.async { [weak self] in 424 + guard let self = self else { return } 425 + if !self.litNotes.isEmpty { 426 + self.litNotes.removeAll() 427 + self.litDownAt.removeAll() 428 + self.onLitChanged?() 429 + } 430 + } 431 + } 432 + }
+168
slab/menuband/Sources/MenuBand/MenuBandMIDI.swift
··· 1 + import Foundation 2 + import CoreMIDI 3 + 4 + // MenuBand keyboard layout → semitone offset from middle C (C4 = MIDI 60). 5 + // Mirrors NOTE_TO_KEYBOARD_KEY in system/public/aesthetic.computer/disks/notepat.mjs 6 + // "-X" = octave below, "+X" = octave above, "++X" = two octaves above. 7 + // Indexed by Carbon kVK_ANSI_* virtual key codes for zero-allocation lookup. 8 + // Keymap raw values are persisted in UserDefaults — keep the `notepat` raw 9 + // value stable so existing settings continue to load. 10 + enum Keymap: String { case notepat, ableton } 11 + 12 + enum MenuBandLayout { 13 + // Pre-built dense lookup: index = virtual key code (0-127), value = semitone or Int8.min if unmapped. 14 + static let semitoneByKeyCode: [Int8] = { 15 + var table = [Int8](repeating: Int8.min, count: 128) 16 + // (kVK_ANSI_*, semitone offset from middle C) 17 + let mapping: [(UInt16, Int8)] = [ 18 + (6, -2), // Z -a# 19 + (7, -1), // X -b 20 + (8, 0), // C c 21 + (9, 1), // V c# 22 + (2, 2), // D d 23 + (1, 3), // S d# 24 + (14, 4), // E e 25 + (3, 5), // F f 26 + (13, 6), // W f# 27 + (5, 7), // G g 28 + (15, 8), // R g# 29 + (0, 9), // A a 30 + (12, 10), // Q a# 31 + (11, 11), // B b 32 + (4, 12), // H +c 33 + (17, 13), // T +c# 34 + (34, 14), // I +d 35 + (16, 15), // Y +d# 36 + (38, 16), // J +e 37 + (40, 17), // K +f 38 + (32, 18), // U +f# 39 + (37, 19), // L +g 40 + (31, 20), // O +g# 41 + (46, 21), // M +a 42 + (35, 22), // P +a# 43 + (45, 23), // N +b 44 + (41, 24), // ; ++c 45 + (39, 25), // ' ++c# 46 + (30, 26), // ] ++d 47 + ] 48 + for (kc, st) in mapping { table[Int(kc)] = st } 49 + return table 50 + }() 51 + 52 + /// Ableton Live's qwerty keymap (M to enable in Live). Home row a..k → C..C 53 + /// across an octave; w/e/t/y/u → black keys; o/p add another octave. 54 + static let semitoneByKeyCodeAbleton: [Int8] = { 55 + var t = [Int8](repeating: Int8.min, count: 128) 56 + let mapping: [(UInt16, Int8)] = [ 57 + (0, 0), // A C 58 + (13, 1), // W C# 59 + (1, 2), // S D 60 + (14, 3), // E D# 61 + (2, 4), // D E 62 + (3, 5), // F F 63 + (17, 6), // T F# 64 + (5, 7), // G G 65 + (16, 8), // Y G# 66 + (4, 9), // H A 67 + (32, 10), // U A# 68 + (38, 11), // J B 69 + (40, 12), // K C+1 70 + (31, 13), // O C#+1 71 + (37, 14), // L D+1 72 + (35, 15), // P D#+1 73 + (41, 16), // ; E+1 74 + ] 75 + for (kc, st) in mapping { t[Int(kc)] = st } 76 + return t 77 + }() 78 + 79 + @inline(__always) 80 + static func midiNote(forKeyCode keyCode: UInt16, 81 + octaveShift: Int, 82 + keymap: Keymap = .notepat) -> UInt8? { 83 + guard keyCode < 128 else { return nil } 84 + let table = (keymap == .ableton) ? semitoneByKeyCodeAbleton : semitoneByKeyCode 85 + let semitone = table[Int(keyCode)] 86 + if semitone == Int8.min { return nil } 87 + let value = 60 + Int(semitone) + (octaveShift * 12) 88 + guard value >= 0, value <= 127 else { return nil } 89 + return UInt8(value) 90 + } 91 + } 92 + 93 + final class MenuBandMIDI { 94 + private var client: MIDIClientRef = 0 95 + private var source: MIDIEndpointRef = 0 96 + private var started = false 97 + 98 + deinit { stop() } 99 + 100 + func start() { 101 + guard !started else { return } 102 + let clientName = "MenuBand" as CFString 103 + let status = MIDIClientCreate(clientName, nil, nil, &client) 104 + guard status == noErr else { 105 + NSLog("MenuBand MIDIClientCreate failed: \(status)") 106 + return 107 + } 108 + let sourceName = "MenuBand" as CFString 109 + let srcStatus = MIDISourceCreate(client, sourceName, &source) 110 + guard srcStatus == noErr else { 111 + NSLog("MenuBand MIDISourceCreate failed: \(srcStatus)") 112 + MIDIClientDispose(client) 113 + client = 0 114 + return 115 + } 116 + started = true 117 + } 118 + 119 + func stop() { 120 + // Best-effort: send all-notes-off so Ableton doesn't hang on stuck notes. 121 + if started { 122 + sendAllNotesOff() 123 + if source != 0 { MIDIEndpointDispose(source) } 124 + if client != 0 { MIDIClientDispose(client) } 125 + source = 0 126 + client = 0 127 + started = false 128 + } 129 + } 130 + 131 + func noteOn(_ note: UInt8, velocity: UInt8 = 100, channel: UInt8 = 0) { 132 + guard started else { return } 133 + send([0x90 | (channel & 0x0F), note & 0x7F, velocity & 0x7F]) 134 + } 135 + 136 + func noteOff(_ note: UInt8, channel: UInt8 = 0) { 137 + guard started else { return } 138 + send([0x80 | (channel & 0x0F), note & 0x7F, 0]) 139 + } 140 + 141 + /// Send a Control Change message. CC 10 = pan (0=left, 64=center, 127=right). 142 + func sendCC(_ cc: UInt8, value: UInt8, channel: UInt8 = 0) { 143 + guard started else { return } 144 + send([0xB0 | (channel & 0x0F), cc & 0x7F, value & 0x7F]) 145 + } 146 + 147 + func sendAllNotesOff(channel: UInt8 = 0) { 148 + guard started else { return } 149 + // CC 123 = All Notes Off 150 + send([0xB0 | (channel & 0x0F), 123, 0]) 151 + } 152 + 153 + // MARK: - Internal 154 + 155 + private func send(_ bytes: [UInt8]) { 156 + var packetList = MIDIPacketList() 157 + let packet = MIDIPacketListInit(&packetList) 158 + // Timestamp 0 → "deliver as soon as possible, no scheduling." For live 159 + // play this is lower latency than scheduling against mach_absolute_time(). 160 + bytes.withUnsafeBufferPointer { buf in 161 + _ = MIDIPacketListAdd(&packetList, MemoryLayout<MIDIPacketList>.size, packet, 0, bytes.count, buf.baseAddress!) 162 + } 163 + let status = MIDIReceived(source, &packetList) 164 + if status != noErr { 165 + NSLog("MenuBand MIDIReceived failed: \(status)") 166 + } 167 + } 168 + }
+356
slab/menuband/Sources/MenuBand/MenuBandPopover.swift
··· 1 + import AppKit 2 + 3 + /// Settings popover for the menubar piano. Custom NSViewController with 4 + /// native AppKit controls (NSSwitch / NSPopUpButton / NSStepper) for a 5 + /// richer feel than a plain NSMenu. 6 + final class MenuBandPopoverViewController: NSViewController { 7 + weak var menuBand: MenuBandController? 8 + 9 + private var typeSwitch: NSSwitch! 10 + private var midiSwitch: NSSwitch! 11 + private var instrumentPopUp: NSPopUpButton! 12 + private var octaveStepper: NSStepper! 13 + private var octaveLabel: NSTextField! 14 + private var keymapSegmented: NSSegmentedControl! 15 + 16 + override func loadView() { 17 + // Plain solid-color background — no NSVisualEffectView. The visual 18 + // effect view sampled the surrounding context and shifted appearance 19 + // when focus moved between the menu bar and the popover. A flat 20 + // background keeps the popover homogeneous in all states. 21 + let root = NSView() 22 + root.wantsLayer = true 23 + root.layer?.backgroundColor = NSColor.windowBackgroundColor.cgColor 24 + root.translatesAutoresizingMaskIntoConstraints = false 25 + 26 + // Vertical stack of rows. 27 + let stack = NSStackView() 28 + stack.orientation = .vertical 29 + stack.alignment = .leading 30 + stack.spacing = 10 31 + stack.edgeInsets = NSEdgeInsets(top: 14, left: 16, bottom: 14, right: 16) 32 + stack.translatesAutoresizingMaskIntoConstraints = false 33 + root.addSubview(stack) 34 + 35 + // Title row. 36 + let title = NSTextField(labelWithString: "MenuBand") 37 + title.font = NSFont.systemFont(ofSize: 13, weight: .bold) 38 + title.textColor = .labelColor 39 + stack.addArrangedSubview(title) 40 + 41 + let subtitle = NSTextField(labelWithString: "Built-in macOS instruments, in the menu bar.") 42 + subtitle.font = NSFont.systemFont(ofSize: 10.5) 43 + subtitle.textColor = .secondaryLabelColor 44 + stack.addArrangedSubview(subtitle) 45 + 46 + stack.addArrangedSubview(makeSeparator()) 47 + 48 + // TYPE switch row — shortcut hint in the sublabel so it's right 49 + // next to its target instead of repeated below. 50 + typeSwitch = NSSwitch() 51 + typeSwitch.target = self 52 + typeSwitch.action = #selector(typeSwitchToggled(_:)) 53 + stack.addArrangedSubview(makeSwitchRow( 54 + label: "Capture keystrokes", 55 + sublabel: "⌃⌥⌘P to toggle", 56 + switchControl: typeSwitch 57 + )) 58 + 59 + // MIDI switch row. 60 + midiSwitch = NSSwitch() 61 + midiSwitch.target = self 62 + midiSwitch.action = #selector(midiSwitchToggled(_:)) 63 + stack.addArrangedSubview(makeSwitchRow( 64 + label: "Send MIDI to DAW", 65 + sublabel: "Routes via virtual port", 66 + switchControl: midiSwitch 67 + )) 68 + 69 + stack.addArrangedSubview(makeSeparator()) 70 + 71 + // Instrument popup row. 72 + let instrumentLabel = NSTextField(labelWithString: "Instrument") 73 + instrumentLabel.font = NSFont.systemFont(ofSize: 11, weight: .semibold) 74 + instrumentLabel.textColor = .labelColor 75 + stack.addArrangedSubview(instrumentLabel) 76 + 77 + instrumentPopUp = NSPopUpButton(frame: .zero, pullsDown: false) 78 + instrumentPopUp.target = self 79 + instrumentPopUp.action = #selector(instrumentChanged(_:)) 80 + instrumentPopUp.translatesAutoresizingMaskIntoConstraints = false 81 + rebuildInstrumentMenu() 82 + stack.addArrangedSubview(instrumentPopUp) 83 + instrumentPopUp.widthAnchor.constraint(equalToConstant: 240).isActive = true 84 + 85 + stack.addArrangedSubview(makeSeparator()) 86 + 87 + // Octave row. 88 + let octaveRow = NSStackView() 89 + octaveRow.orientation = .horizontal 90 + octaveRow.alignment = .centerY 91 + octaveRow.spacing = 8 92 + let octaveTitle = NSTextField(labelWithString: "Octave") 93 + octaveTitle.font = NSFont.systemFont(ofSize: 11, weight: .semibold) 94 + octaveTitle.textColor = .labelColor 95 + octaveLabel = NSTextField(labelWithString: "+0") 96 + octaveLabel.font = NSFont.monospacedSystemFont(ofSize: 11, weight: .bold) 97 + octaveLabel.textColor = .labelColor 98 + octaveLabel.alignment = .center 99 + octaveLabel.widthAnchor.constraint(equalToConstant: 28).isActive = true 100 + octaveStepper = NSStepper() 101 + octaveStepper.minValue = -4 102 + octaveStepper.maxValue = 4 103 + octaveStepper.increment = 1 104 + octaveStepper.valueWraps = false 105 + octaveStepper.target = self 106 + octaveStepper.action = #selector(octaveChanged(_:)) 107 + let octaveReset = NSButton(title: "Reset", target: self, action: #selector(resetOctave)) 108 + octaveReset.bezelStyle = .recessed 109 + octaveReset.controlSize = .small 110 + octaveRow.addArrangedSubview(octaveTitle) 111 + octaveRow.addArrangedSubview(octaveLabel) 112 + octaveRow.addArrangedSubview(octaveStepper) 113 + octaveRow.addArrangedSubview(octaveReset) 114 + stack.addArrangedSubview(octaveRow) 115 + 116 + stack.addArrangedSubview(makeSeparator()) 117 + 118 + // Keymap toggle (MenuBand / Ableton). 119 + let keymapLabel = NSTextField(labelWithString: "Keymap") 120 + keymapLabel.font = NSFont.systemFont(ofSize: 11, weight: .semibold) 121 + keymapLabel.textColor = .labelColor 122 + stack.addArrangedSubview(keymapLabel) 123 + 124 + keymapSegmented = NSSegmentedControl(labels: ["MenuBand", "Ableton"], 125 + trackingMode: .selectOne, 126 + target: self, 127 + action: #selector(keymapChanged(_:))) 128 + keymapSegmented.translatesAutoresizingMaskIntoConstraints = false 129 + stack.addArrangedSubview(keymapSegmented) 130 + keymapSegmented.widthAnchor.constraint(equalToConstant: 240).isActive = true 131 + 132 + stack.addArrangedSubview(makeSeparator()) 133 + 134 + // About — inline rather than a separate dialog. 135 + let aboutTitle = NSTextField(labelWithString: "About") 136 + aboutTitle.font = NSFont.systemFont(ofSize: 11, weight: .semibold) 137 + aboutTitle.textColor = .labelColor 138 + stack.addArrangedSubview(aboutTitle) 139 + 140 + let aboutBody = NSTextField(wrappingLabelWithString: 141 + "A political project to bring the built-in macOS instruments — " + 142 + "the ones GarageBand uses — into the menu bar. Accessible " + 143 + "music-making is as essential as time, network connectivity, " + 144 + "and battery life.") 145 + aboutBody.font = NSFont.systemFont(ofSize: 10.5) 146 + aboutBody.textColor = .secondaryLabelColor 147 + aboutBody.maximumNumberOfLines = 0 148 + aboutBody.preferredMaxLayoutWidth = 248 149 + stack.addArrangedSubview(aboutBody) 150 + 151 + let linksRow = NSStackView() 152 + linksRow.orientation = .horizontal 153 + linksRow.alignment = .centerY 154 + linksRow.spacing = 8 155 + let acLink = NSButton(title: "aesthetic.computer", 156 + target: self, action: #selector(openAesthetic)) 157 + acLink.bezelStyle = .recessed 158 + acLink.controlSize = .small 159 + let npLink = NSButton(title: "notepat.com", 160 + target: self, action: #selector(openNotepat)) 161 + npLink.bezelStyle = .recessed 162 + npLink.controlSize = .small 163 + linksRow.addArrangedSubview(acLink) 164 + linksRow.addArrangedSubview(npLink) 165 + stack.addArrangedSubview(linksRow) 166 + 167 + stack.addArrangedSubview(makeSeparator()) 168 + 169 + // Quit — red stop button so it's visually distinct from the 170 + // toggles/links above. 171 + let quit = NSButton() 172 + quit.title = "Quit MenuBand" 173 + quit.image = NSImage(systemSymbolName: "stop.circle.fill", 174 + accessibilityDescription: "Quit") 175 + quit.imagePosition = .imageLeading 176 + quit.bezelStyle = .recessed 177 + quit.controlSize = .small 178 + quit.contentTintColor = NSColor.systemRed 179 + quit.target = self 180 + quit.action = #selector(quitApp) 181 + let quitTitle = NSAttributedString( 182 + string: "Quit MenuBand", 183 + attributes: [ 184 + .foregroundColor: NSColor.systemRed, 185 + .font: NSFont.systemFont(ofSize: 11, weight: .semibold), 186 + ] 187 + ) 188 + quit.attributedTitle = quitTitle 189 + stack.addArrangedSubview(quit) 190 + 191 + NSLayoutConstraint.activate([ 192 + stack.leadingAnchor.constraint(equalTo: root.leadingAnchor), 193 + stack.trailingAnchor.constraint(equalTo: root.trailingAnchor), 194 + stack.topAnchor.constraint(equalTo: root.topAnchor), 195 + stack.bottomAnchor.constraint(equalTo: root.bottomAnchor), 196 + ]) 197 + 198 + view = root 199 + preferredContentSize = NSSize(width: 280, height: 320) 200 + } 201 + 202 + /// Refresh control state from the controller — call right before showing. 203 + func syncFromController() { 204 + guard isViewLoaded, let n = menuBand else { return } 205 + typeSwitch.state = n.typeMode ? .on : .off 206 + midiSwitch.state = n.midiMode ? .on : .off 207 + octaveStepper.integerValue = n.octaveShift 208 + updateOctaveLabel(n.octaveShift) 209 + keymapSegmented.selectedSegment = (n.keymap == .ableton) ? 1 : 0 210 + let target = Int(n.melodicProgram) 211 + for item in instrumentPopUp.itemArray { 212 + if item.tag == target { 213 + instrumentPopUp.select(item) 214 + break 215 + } 216 + } 217 + } 218 + 219 + // MARK: - Builders 220 + 221 + private func makeSeparator() -> NSView { 222 + let box = NSBox() 223 + box.boxType = .separator 224 + return box 225 + } 226 + 227 + private func makeSwitchRow(label: String, 228 + sublabel: String, 229 + switchControl: NSSwitch) -> NSView { 230 + let row = NSStackView() 231 + row.orientation = .horizontal 232 + row.alignment = .centerY 233 + row.spacing = 10 234 + row.distribution = .fill 235 + 236 + let labelStack = NSStackView() 237 + labelStack.orientation = .vertical 238 + labelStack.alignment = .leading 239 + labelStack.spacing = 1 240 + 241 + let title = NSTextField(labelWithString: label) 242 + title.font = NSFont.systemFont(ofSize: 12, weight: .semibold) 243 + title.textColor = .labelColor 244 + 245 + let sub = NSTextField(labelWithString: sublabel) 246 + sub.font = NSFont.systemFont(ofSize: 10) 247 + sub.textColor = .secondaryLabelColor 248 + 249 + labelStack.addArrangedSubview(title) 250 + labelStack.addArrangedSubview(sub) 251 + 252 + row.addArrangedSubview(labelStack) 253 + let spacer = NSView() 254 + spacer.setContentHuggingPriority(.defaultLow, for: .horizontal) 255 + row.addArrangedSubview(spacer) 256 + row.addArrangedSubview(switchControl) 257 + return row 258 + } 259 + 260 + private func rebuildInstrumentMenu() { 261 + let menu = NSMenu() 262 + for (familyName, range) in GeneralMIDI.families { 263 + let parent = NSMenuItem(title: familyName, action: nil, keyEquivalent: "") 264 + let sub = NSMenu() 265 + for prog in range { 266 + let title = String(format: "%03d %@", prog, GeneralMIDI.programNames[prog]) 267 + let item = NSMenuItem(title: title, action: nil, keyEquivalent: "") 268 + item.tag = prog 269 + sub.addItem(item) 270 + } 271 + parent.submenu = sub 272 + menu.addItem(parent) 273 + } 274 + // Flatten — NSPopUpButton displays leaf selections better when the 275 + // popUp menu has flat items. Build a flat menu instead with section 276 + // headers. 277 + let flat = NSMenu() 278 + for (familyName, range) in GeneralMIDI.families { 279 + let header = NSMenuItem(title: familyName.uppercased(), action: nil, keyEquivalent: "") 280 + header.isEnabled = false 281 + flat.addItem(header) 282 + for prog in range { 283 + let title = String(format: " %03d %@", prog, GeneralMIDI.programNames[prog]) 284 + let item = NSMenuItem(title: title, action: nil, keyEquivalent: "") 285 + item.tag = prog 286 + flat.addItem(item) 287 + } 288 + } 289 + instrumentPopUp.menu = flat 290 + } 291 + 292 + private func updateOctaveLabel(_ shift: Int) { 293 + let s: String 294 + if shift == 0 { s = "0" } 295 + else if shift > 0 { s = "+\(shift)" } 296 + else { s = "\(shift)" } 297 + octaveLabel.stringValue = s 298 + } 299 + 300 + // MARK: - Actions 301 + 302 + @objc private func typeSwitchToggled(_ sender: NSSwitch) { 303 + menuBand?.toggleTypeMode() 304 + // Re-read in case Accessibility prompt cancelled. 305 + syncFromController() 306 + } 307 + 308 + @objc private func midiSwitchToggled(_ sender: NSSwitch) { 309 + menuBand?.toggleMIDIMode() 310 + syncFromController() 311 + } 312 + 313 + @objc private func instrumentChanged(_ sender: NSPopUpButton) { 314 + guard let item = sender.selectedItem, item.tag >= 0 else { return } 315 + menuBand?.setMelodicProgram(UInt8(item.tag)) 316 + } 317 + 318 + @objc private func octaveChanged(_ sender: NSStepper) { 319 + menuBand?.octaveShift = sender.integerValue 320 + updateOctaveLabel(sender.integerValue) 321 + } 322 + 323 + @objc private func resetOctave() { 324 + menuBand?.octaveShift = 0 325 + octaveStepper.integerValue = 0 326 + updateOctaveLabel(0) 327 + } 328 + 329 + @objc private func keymapChanged(_ sender: NSSegmentedControl) { 330 + menuBand?.keymap = (sender.selectedSegment == 1) ? .ableton : .notepat 331 + } 332 + 333 + @objc private func openAesthetic() { 334 + if let url = URL(string: "https://aesthetic.computer") { 335 + NSWorkspace.shared.open(url) 336 + } 337 + } 338 + 339 + @objc private func openNotepat() { 340 + if let url = URL(string: "https://notepat.com") { 341 + NSWorkspace.shared.open(url) 342 + } 343 + } 344 + 345 + @objc private func quitApp() { 346 + // Unload our LaunchAgent first so launchd doesn't immediately 347 + // relaunch us, then terminate. 348 + let plist = "\(NSHomeDirectory())/Library/LaunchAgents/computer.aestheticcomputer.menuband.plist" 349 + let task = Process() 350 + task.launchPath = "/bin/launchctl" 351 + task.arguments = ["unload", plist] 352 + try? task.run() 353 + task.waitUntilExit() 354 + NSApp.terminate(nil) 355 + } 356 + }
+126
slab/menuband/Sources/MenuBand/MenuBandSynth.swift
··· 1 + import Foundation 2 + import AVFoundation 3 + 4 + // Built-in soft-synth using Apple's bundled GM DLS sound bank 5 + // (`gs_instruments.dls`, shipped with every macOS install inside 6 + // CoreAudio.component). Real piano and drum-kit samples — not the bare 7 + // AVAudioUnitSampler default, which is a sine-ish placeholder. 8 + // 9 + // Two samplers are kept: one melodic (channel 0 — piano by default), one 10 + // percussion (channel 9 — GM drum kit). MenuBandController routes drum notes 11 + // to the drum sampler. 12 + final class MenuBandSynth { 13 + private let engine = AVAudioEngine() 14 + private let melodic = AVAudioUnitSampler() 15 + private let drums = AVAudioUnitSampler() 16 + private var started = false 17 + 18 + // Apple's DLS bank — present on every macOS install since 10.x. 19 + private static let bankURL = URL( 20 + fileURLWithPath: "/System/Library/Components/CoreAudio.component/Contents/Resources/gs_instruments.dls" 21 + ) 22 + 23 + func start() { 24 + guard !started else { return } 25 + engine.attach(melodic) 26 + engine.attach(drums) 27 + engine.connect(melodic, to: engine.mainMixerNode, format: nil) 28 + engine.connect(drums, to: engine.mainMixerNode, format: nil) 29 + engine.prepare() // pre-allocate buffers before .start() 30 + do { 31 + try engine.start() 32 + started = true 33 + } catch { 34 + NSLog("MenuBand synth engine start failed: \(error)") 35 + return 36 + } 37 + loadDefaultPatches() 38 + primeForLowLatency() 39 + } 40 + 41 + /// Plays inaudible velocity-1 notes through both samplers immediately 42 + /// after start(). This forces sample-bank loads and audio-thread warmup 43 + /// to happen NOW instead of on the user's first real tap, so fast taps 44 + /// trigger sound with no perceptible delay. 45 + private func primeForLowLatency() { 46 + // Warmup notes: a few across the range so the sampler caches more of 47 + // its sample map. Velocity 1 is essentially silent. 48 + let melodicWarmup: [UInt8] = [60, 64, 67, 72] 49 + let drumWarmup: [UInt8] = [36, 38, 42, 46] 50 + for n in melodicWarmup { 51 + melodic.startNote(n, withVelocity: 1, onChannel: 0) 52 + } 53 + for n in drumWarmup { 54 + drums.startNote(n, withVelocity: 1, onChannel: 0) 55 + } 56 + // Stop them on the next run loop tick so the engine actually 57 + // schedules the start side first. 58 + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in 59 + guard let self = self else { return } 60 + for n in melodicWarmup { self.melodic.stopNote(n, onChannel: 0) } 61 + for n in drumWarmup { self.drums.stopNote(n, onChannel: 0) } 62 + } 63 + } 64 + 65 + private func loadDefaultPatches() { 66 + let url = MenuBandSynth.bankURL 67 + guard FileManager.default.fileExists(atPath: url.path) else { 68 + NSLog("MenuBand: gs_instruments.dls not found — falling back to default sampler tone") 69 + return 70 + } 71 + // CoreAudio's DLS bank uses Roland's GS conventions: 72 + // melodic instruments — bankMSB = 0x79 (kAUSampler_DefaultMelodicBankMSB) 73 + // percussion — bankMSB = 0x78 (kAUSampler_DefaultPercussionBankMSB) 74 + // Program 0 on each = the standard kit / acoustic grand piano. 75 + let melodicMSB: UInt8 = 0x79 76 + let percussionMSB: UInt8 = 0x78 77 + let lsb: UInt8 = 0 78 + do { 79 + try melodic.loadSoundBankInstrument(at: url, program: 0, bankMSB: melodicMSB, bankLSB: lsb) 80 + } catch { 81 + NSLog("MenuBand: melodic patch load failed: \(error)") 82 + } 83 + do { 84 + try drums.loadSoundBankInstrument(at: url, program: 0, bankMSB: percussionMSB, bankLSB: lsb) 85 + } catch { 86 + NSLog("MenuBand: drum kit load failed: \(error)") 87 + } 88 + } 89 + 90 + /// Switch the melodic sampler to a different GM program (0–127). 91 + /// Examples: 0=piano, 4=electric piano, 24=nylon guitar, 32=acoustic bass, 92 + /// 40=violin, 48=string ensemble, 56=trumpet, 73=flute, 80=square lead. 93 + func setMelodicProgram(_ program: UInt8) { 94 + guard started else { return } 95 + let url = MenuBandSynth.bankURL 96 + guard FileManager.default.fileExists(atPath: url.path) else { return } 97 + try? melodic.loadSoundBankInstrument(at: url, program: program, bankMSB: 0x79, bankLSB: 0) 98 + } 99 + 100 + func stop() { 101 + guard started else { return } 102 + engine.stop() 103 + started = false 104 + } 105 + 106 + func noteOn(_ midi: UInt8, velocity: UInt8 = 100, channel: UInt8 = 0) { 107 + guard started else { return } 108 + // Each AVAudioUnitSampler is itself single-channel; we pick which 109 + // sampler based on the *requested* channel. 110 + let unit = (channel == 9) ? drums : melodic 111 + unit.startNote(midi, withVelocity: velocity, onChannel: 0) 112 + } 113 + 114 + func noteOff(_ midi: UInt8, channel: UInt8 = 0) { 115 + guard started else { return } 116 + let unit = (channel == 9) ? drums : melodic 117 + unit.stopNote(midi, onChannel: 0) 118 + } 119 + 120 + func panic() { 121 + guard started else { return } 122 + for unit in [melodic, drums] { 123 + for note: UInt8 in 0...127 { unit.stopNote(note, onChannel: 0) } 124 + } 125 + } 126 + }