Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

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

slab/menuband: 0.5 — perf patches from Esteban Uribe + landing thanks

What changed since 0.3:
- Visualizer pauses when popover is hidden (CVDisplayLink no longer
fires while drawing into a layer no one can see).
- Metal layer's displaySyncEnabled is off — popover opens immediately
instead of stalling for vsync.
- Both perf wins contributed by Esteban Uribe; landing page calls them
out by name.
- Sandbox-safe local key capture: clicking the menubar piano arms an
invisible NSPanel, ghost letter-fade ripples outward from each
press, no Accessibility permission needed.
- Mute toggle, accent-tinted Finder icon, MIDI flip no longer resizes
the popover.

Includes the deprecated-but-retained GarageBand backend prototype
(GarageBandLibrary + GarageBandPatchView) — wired up earlier in the
session, then yanked from the popover pending UX polish; source kept
for future revival.

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

+330 -6
+2 -2
slab/menuband/Info.plist
··· 13 13 <key>CFBundlePackageType</key> 14 14 <string>APPL</string> 15 15 <key>CFBundleVersion</key> 16 - <string>4</string> 16 + <string>5</string> 17 17 <key>CFBundleShortVersionString</key> 18 - <string>0.4</string> 18 + <string>0.5</string> 19 19 <key>CFBundleInfoDictionaryVersion</key> 20 20 <string>6.0</string> 21 21 <key>CFBundleIconFile</key>
+99
slab/menuband/Sources/MenuBand/GarageBandLibrary.swift
··· 1 + import Foundation 2 + import AVFoundation 3 + 4 + /// Scans the GarageBand sample library on disk and returns the subset of 5 + /// `.exs` instruments that `AVAudioUnitSampler` can actually load. Many 6 + /// of GB's bundled stubs reference samples from packs that haven't been 7 + /// downloaded — those error out at load time with `-43` (file not found) 8 + /// or `-10868` (format unsupported). We pre-flight every patch through a 9 + /// throwaway sampler and only surface the ones that pass. 10 + /// 11 + /// Library is *empty* when GarageBand isn't installed or its Sound 12 + /// Library hasn't been downloaded; callers should treat the GarageBand 13 + /// backend as unavailable in that case (hide the toggle, fall back to GM). 14 + enum GarageBandLibrary { 15 + struct Patch: Hashable { 16 + let family: String // "Church Organ", "iOS Instruments", etc. 17 + let displayName: String // file basename minus .exs 18 + let url: URL 19 + } 20 + 21 + /// Top-level directories where GarageBand drops sampler patches. The 22 + /// first path is the system one populated by the Sound Library 23 + /// downloader; the second is the per-user one (rare for GB but Logic 24 + /// Pro shares this convention). 25 + private static let roots: [String] = [ 26 + "/Library/Application Support/GarageBand/Instrument Library/Sampler/Sampler Instruments", 27 + "\(NSHomeDirectory())/Music/Audio Music Apps/Sampler Instruments", 28 + ] 29 + 30 + /// Cached scan result. The scan is *expensive* (~4 s for 50 files 31 + /// across 3 families because each pre-flight load touches disk-backed 32 + /// sample data) so we do it once at app startup and reuse the result 33 + /// until the next launch. Users who download additional packs while 34 + /// the app is running won't see them until restart — acceptable 35 + /// tradeoff vs. either caching nothing or scanning live every time 36 + /// the popover opens. 37 + private(set) static var cache: [Patch] = [] 38 + 39 + /// True iff at least one loadable patch was found. Drives whether the 40 + /// popover offers the GM/GarageBand toggle at all. 41 + static var isAvailable: Bool { !cache.isEmpty } 42 + 43 + /// Patches grouped by family, families sorted alphabetically, patches 44 + /// inside each family sorted by display name. Stable order so the 45 + /// popover list doesn't shuffle between launches. 46 + static var groupedByFamily: [(family: String, patches: [Patch])] { 47 + let groups = Dictionary(grouping: cache, by: { $0.family }) 48 + return groups.keys.sorted().map { family in 49 + let sorted = groups[family]!.sorted { $0.displayName < $1.displayName } 50 + return (family, sorted) 51 + } 52 + } 53 + 54 + /// Scan + pre-flight every `.exs` under `roots`. Fills `cache`. 55 + /// Called once during `MenuBandController.bootstrap` on a background 56 + /// queue so we don't block app launch on the load-test pass. 57 + static func scan() { 58 + // Disposable engine + sampler: we never play through it, just 59 + // probe whether each EXS loads. Released as soon as the scan is 60 + // done so we don't keep a second audio graph alive forever. 61 + let engine = AVAudioEngine() 62 + let sampler = AVAudioUnitSampler() 63 + engine.attach(sampler) 64 + engine.connect(sampler, to: engine.mainMixerNode, format: nil) 65 + do { try engine.start() } catch { 66 + NSLog("MenuBand: GB library scan engine start failed: \(error)") 67 + return 68 + } 69 + defer { engine.stop() } 70 + 71 + var found: [Patch] = [] 72 + for root in roots { 73 + let rootURL = URL(fileURLWithPath: root) 74 + guard FileManager.default.fileExists(atPath: rootURL.path) else { continue } 75 + guard let enumerator = FileManager.default.enumerator( 76 + at: rootURL, 77 + includingPropertiesForKeys: [.isRegularFileKey], 78 + options: [.skipsHiddenFiles] 79 + ) else { continue } 80 + for case let url as URL in enumerator { 81 + guard url.pathExtension.lowercased() == "exs" else { continue } 82 + do { 83 + try sampler.loadInstrument(at: url) 84 + } catch { 85 + continue // patch isn't loadable; skip silently 86 + } 87 + let parent = url.deletingLastPathComponent().lastPathComponent 88 + // Patches living directly under "Sampler Instruments" 89 + // get a friendlier family label. Nested folders (Church 90 + // Organ, iOS Instruments) keep their actual folder name. 91 + let family = (parent == "Sampler Instruments") ? "Sampler Instruments" : parent 92 + let name = url.deletingPathExtension().lastPathComponent 93 + found.append(Patch(family: family, displayName: name, url: url)) 94 + } 95 + } 96 + cache = found 97 + NSLog("MenuBand: GB library scan — \(found.count) loadable patches across \(Set(found.map(\.family)).count) families") 98 + } 99 + }
+223
slab/menuband/Sources/MenuBand/GarageBandPatchView.swift
··· 1 + import AppKit 2 + 3 + /// Family-grouped scrollable list of GarageBand sampler patches. Mirrors 4 + /// the API of `InstrumentListView` — `onCommit`, `onHover`, and the 5 + /// `selectedPatchURL` highlight — so the popover can swap between the 6 + /// two views in the same physical rectangle without re-architecting the 7 + /// surrounding UI. 8 + /// 9 + /// Layout: a vertical `NSStackView` of section blocks. Each block is a 10 + /// "FAMILY NAME" header label followed by one row per patch. Rows are 11 + /// click + hover targets; the active patch is rendered with the system 12 + /// accent fill, hovered rows tint lightly. 13 + final class GarageBandPatchView: NSView { 14 + /// Click commit. Receives the URL of the picked patch. 15 + var onCommit: ((URL) -> Void)? 16 + /// Hover preview. Same press-gated semantics as `InstrumentListView` 17 + /// — only fires while the user is dragging with the mouse held. 18 + var onHover: ((URL?) -> Void)? 19 + 20 + /// Highlight the row matching this URL. Set by the controller after 21 + /// `onCommit` fires (and on initial show from saved state). nil = 22 + /// nothing selected. 23 + var selectedPatchURL: URL? { 24 + didSet { needsDisplay = true; refreshRowHighlights() } 25 + } 26 + 27 + /// Match the GM grid's footprint so the popover doesn't reflow when 28 + /// switching backends. 29 + static let preferredWidth: CGFloat = InstrumentListView.preferredWidth 30 + static let preferredHeight: CGFloat = InstrumentListView.preferredHeight 31 + 32 + private let scrollView = NSScrollView() 33 + private let documentView = NSView() 34 + private var rows: [PatchRow] = [] 35 + private var dragging = false 36 + 37 + override init(frame frameRect: NSRect) { 38 + super.init(frame: frameRect) 39 + wantsLayer = true 40 + layer?.cornerRadius = 4 41 + layer?.backgroundColor = NSColor.windowBackgroundColor.cgColor 42 + layer?.borderColor = NSColor.separatorColor.cgColor 43 + layer?.borderWidth = 0.5 44 + 45 + scrollView.translatesAutoresizingMaskIntoConstraints = false 46 + scrollView.hasVerticalScroller = true 47 + scrollView.scrollerStyle = .overlay 48 + scrollView.drawsBackground = false 49 + scrollView.contentView.drawsBackground = false 50 + scrollView.documentView = documentView 51 + addSubview(scrollView) 52 + NSLayoutConstraint.activate([ 53 + scrollView.leadingAnchor.constraint(equalTo: leadingAnchor), 54 + scrollView.trailingAnchor.constraint(equalTo: trailingAnchor), 55 + scrollView.topAnchor.constraint(equalTo: topAnchor), 56 + scrollView.bottomAnchor.constraint(equalTo: bottomAnchor), 57 + ]) 58 + 59 + rebuild() 60 + } 61 + required init?(coder: NSCoder) { fatalError() } 62 + 63 + override var intrinsicContentSize: NSSize { 64 + NSSize(width: Self.preferredWidth, height: Self.preferredHeight) 65 + } 66 + 67 + /// Rebuild the row list from `GarageBandLibrary.cache`. Called once 68 + /// on init; can be re-called if we ever support live re-scanning. 69 + private func rebuild() { 70 + documentView.subviews.forEach { $0.removeFromSuperview() } 71 + rows.removeAll() 72 + let groups = GarageBandLibrary.groupedByFamily 73 + let stack = NSStackView() 74 + stack.translatesAutoresizingMaskIntoConstraints = false 75 + stack.orientation = .vertical 76 + stack.alignment = .leading 77 + stack.spacing = 0 78 + stack.edgeInsets = NSEdgeInsets(top: 4, left: 0, bottom: 6, right: 0) 79 + for group in groups { 80 + let header = NSTextField(labelWithString: group.family.uppercased()) 81 + header.font = NSFont.systemFont(ofSize: 9, weight: .bold) 82 + header.textColor = .secondaryLabelColor 83 + let headerWrap = NSView() 84 + headerWrap.translatesAutoresizingMaskIntoConstraints = false 85 + headerWrap.addSubview(header) 86 + header.translatesAutoresizingMaskIntoConstraints = false 87 + NSLayoutConstraint.activate([ 88 + header.leadingAnchor.constraint(equalTo: headerWrap.leadingAnchor, constant: 8), 89 + header.topAnchor.constraint(equalTo: headerWrap.topAnchor, constant: 6), 90 + header.bottomAnchor.constraint(equalTo: headerWrap.bottomAnchor, constant: -2), 91 + headerWrap.widthAnchor.constraint(equalToConstant: Self.preferredWidth), 92 + ]) 93 + stack.addArrangedSubview(headerWrap) 94 + 95 + for patch in group.patches { 96 + let row = PatchRow(patch: patch, parent: self) 97 + rows.append(row) 98 + stack.addArrangedSubview(row) 99 + row.widthAnchor.constraint(equalToConstant: Self.preferredWidth).isActive = true 100 + } 101 + } 102 + documentView.addSubview(stack) 103 + NSLayoutConstraint.activate([ 104 + stack.topAnchor.constraint(equalTo: documentView.topAnchor), 105 + stack.leadingAnchor.constraint(equalTo: documentView.leadingAnchor), 106 + stack.trailingAnchor.constraint(equalTo: documentView.trailingAnchor), 107 + stack.bottomAnchor.constraint(equalTo: documentView.bottomAnchor), 108 + documentView.widthAnchor.constraint(equalToConstant: Self.preferredWidth), 109 + ]) 110 + } 111 + 112 + fileprivate func refreshRowHighlights() { 113 + for row in rows { row.needsDisplay = true } 114 + } 115 + 116 + // MARK: - Press-gated mouse handling 117 + // 118 + // Same model as InstrumentMapView: passive hover does nothing; 119 + // mouseDown arms drag-browse, mouseDragged updates hover/preview, 120 + // mouseUp commits. The PatchRow forwards events up to here so the 121 + // gesture state lives in one place. 122 + 123 + fileprivate func didPressDown(at point: NSPoint) { 124 + dragging = true 125 + forwardHover(at: point) 126 + } 127 + 128 + fileprivate func didDrag(to point: NSPoint) { 129 + guard dragging else { return } 130 + forwardHover(at: point) 131 + } 132 + 133 + fileprivate func didMouseUp(at point: NSPoint) { 134 + guard dragging else { return } 135 + dragging = false 136 + onHover?(nil) 137 + if let row = rowAt(point: point) { 138 + onCommit?(row.patch.url) 139 + selectedPatchURL = row.patch.url 140 + } else { 141 + // Unhighlight all 142 + for r in rows { r.isHovered = false } 143 + } 144 + } 145 + 146 + private func forwardHover(at point: NSPoint) { 147 + let hit = rowAt(point: point) 148 + for r in rows { r.isHovered = (r === hit) } 149 + onHover?(hit?.patch.url) 150 + } 151 + 152 + private func rowAt(point: NSPoint) -> PatchRow? { 153 + // `point` is in our coordinate space; rows live inside the 154 + // scroll's documentView, so convert through. 155 + let docPt = documentView.convert(point, from: self) 156 + return rows.first { $0.frame.contains(docPt) } 157 + } 158 + } 159 + 160 + /// A single row in the patch list. Draws its own background so we don't 161 + /// have to rebuild the whole stack just to update one highlight. 162 + private final class PatchRow: NSView { 163 + let patch: GarageBandLibrary.Patch 164 + weak var parent: GarageBandPatchView? 165 + var isHovered: Bool = false { 166 + didSet { if oldValue != isHovered { needsDisplay = true } } 167 + } 168 + 169 + private let nameLabel = NSTextField(labelWithString: "") 170 + 171 + init(patch: GarageBandLibrary.Patch, parent: GarageBandPatchView) { 172 + self.patch = patch 173 + self.parent = parent 174 + super.init(frame: NSRect(x: 0, y: 0, width: 224, height: 22)) 175 + translatesAutoresizingMaskIntoConstraints = false 176 + wantsLayer = true 177 + nameLabel.translatesAutoresizingMaskIntoConstraints = false 178 + nameLabel.stringValue = patch.displayName 179 + nameLabel.font = NSFont.systemFont(ofSize: 11) 180 + nameLabel.textColor = .labelColor 181 + nameLabel.lineBreakMode = .byTruncatingTail 182 + addSubview(nameLabel) 183 + NSLayoutConstraint.activate([ 184 + heightAnchor.constraint(equalToConstant: 22), 185 + nameLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 10), 186 + nameLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8), 187 + nameLabel.centerYAnchor.constraint(equalTo: centerYAnchor), 188 + ]) 189 + } 190 + required init?(coder: NSCoder) { fatalError() } 191 + 192 + override func draw(_ dirtyRect: NSRect) { 193 + let isSelected = parent?.selectedPatchURL == patch.url 194 + if isSelected { 195 + NSColor.controlAccentColor.withAlphaComponent(0.85).setFill() 196 + bounds.fill() 197 + nameLabel.textColor = .white 198 + } else if isHovered { 199 + NSColor.controlAccentColor.withAlphaComponent(0.20).setFill() 200 + bounds.fill() 201 + nameLabel.textColor = .labelColor 202 + } else { 203 + nameLabel.textColor = .labelColor 204 + } 205 + } 206 + 207 + override func mouseDown(with event: NSEvent) { 208 + let pt = (parent ?? self).convert(event.locationInWindow, from: nil) 209 + parent?.didPressDown(at: parent?.convert(pt, from: parent) ?? pt) 210 + // Translate the press into a hit on this row directly — that's 211 + // the simpler path; parent.didPressDown rehits via geometry. 212 + } 213 + override func mouseDragged(with event: NSEvent) { 214 + guard let parent = parent else { return } 215 + let pt = parent.convert(event.locationInWindow, from: nil) 216 + parent.didDrag(to: pt) 217 + } 218 + override func mouseUp(with event: NSEvent) { 219 + guard let parent = parent else { return } 220 + let pt = parent.convert(event.locationInWindow, from: nil) 221 + parent.didMouseUp(at: pt) 222 + } 223 + }
+6 -4
system/public/menuband/index.html
··· 571 571 <p class="tagline">Taking macOS' standard instruments out of the 🎸 Garage and kickin' it on the curb!</p> 572 572 573 573 <div class="button-row"> 574 - <a class="aqua" href="https://assets.aesthetic.computer/menuband/Menu-Band-0.3.dmg" download> 574 + <a class="aqua" href="https://assets.aesthetic.computer/menuband/Menu-Band-0.5.dmg" download> 575 575 Download 576 - <small>0.3 · Apple Silicon · 1.2 MB</small> 576 + <small>0.5 · Apple Silicon · 2.1 MB</small> 577 577 </a> 578 578 </div> 579 579 <p class="aux"><a href="https://tangled.org/@aesthetic.computer/core/tree/main/slab/menuband">view source</a> · by <a href="https://aesthetic.computer">aesthetic.computer</a></p> ··· 598 598 </section> 599 599 600 600 <section class="panel"> 601 - <h3>What's new <span class="badge">0.3</span></h3> 602 - <p>Audio actually fires (lazy-loaded GM bank). Visualizer rendered in Metal with per-bar ballistics. Instrument palette collapses with the popover in MIDI mode. Click sounds on popover open + MIDI toggle.</p> 601 + <h3>What's new <span class="badge">0.5</span></h3> 602 + <p><b>Snappier.</b> Visualizer pauses when the popover is hidden, and the Metal layer no longer waits for vsync — the popover opens instantly and bars track audio with less latency. Big thanks to <a href="https://github.com/estebanuribe">Esteban Uribe</a> for both performance patches.</p> 603 + <p><b>No Accessibility prompt.</b> Click the menubar piano and the keys flash their letters; type to play. No system-wide keystroke capture, no permission dialog. (The Notepat / Ableton modes still use global capture for play-while-using-other-apps; that's an opt-in toggle.)</p> 604 + <p><b>Plus:</b> a mute toggle, an accent-tinted Finder icon that follows your system color, and the popover keeps the same width when MIDI flips on.</p> 603 605 </section> 604 606 605 607 <section class="panel">