diff --git a/slab/menuband/Info.plist b/slab/menuband/Info.plist
index 1537fdd5a..89c1d2e6e 100644
--- a/slab/menuband/Info.plist
+++ b/slab/menuband/Info.plist
@@ -13,9 +13,9 @@
CFBundlePackageType
APPL
CFBundleVersion
- 4
+ 5
CFBundleShortVersionString
- 0.4
+ 0.5
CFBundleInfoDictionaryVersion
6.0
CFBundleIconFile
diff --git a/slab/menuband/Sources/MenuBand/GarageBandLibrary.swift b/slab/menuband/Sources/MenuBand/GarageBandLibrary.swift
new file mode 100644
index 000000000..54278126e
--- /dev/null
+++ b/slab/menuband/Sources/MenuBand/GarageBandLibrary.swift
@@ -0,0 +1,99 @@
+import Foundation
+import AVFoundation
+
+/// Scans the GarageBand sample library on disk and returns the subset of
+/// `.exs` instruments that `AVAudioUnitSampler` can actually load. Many
+/// of GB's bundled stubs reference samples from packs that haven't been
+/// downloaded — those error out at load time with `-43` (file not found)
+/// or `-10868` (format unsupported). We pre-flight every patch through a
+/// throwaway sampler and only surface the ones that pass.
+///
+/// Library is *empty* when GarageBand isn't installed or its Sound
+/// Library hasn't been downloaded; callers should treat the GarageBand
+/// backend as unavailable in that case (hide the toggle, fall back to GM).
+enum GarageBandLibrary {
+ struct Patch: Hashable {
+ let family: String // "Church Organ", "iOS Instruments", etc.
+ let displayName: String // file basename minus .exs
+ let url: URL
+ }
+
+ /// Top-level directories where GarageBand drops sampler patches. The
+ /// first path is the system one populated by the Sound Library
+ /// downloader; the second is the per-user one (rare for GB but Logic
+ /// Pro shares this convention).
+ private static let roots: [String] = [
+ "/Library/Application Support/GarageBand/Instrument Library/Sampler/Sampler Instruments",
+ "\(NSHomeDirectory())/Music/Audio Music Apps/Sampler Instruments",
+ ]
+
+ /// Cached scan result. The scan is *expensive* (~4 s for 50 files
+ /// across 3 families because each pre-flight load touches disk-backed
+ /// sample data) so we do it once at app startup and reuse the result
+ /// until the next launch. Users who download additional packs while
+ /// the app is running won't see them until restart — acceptable
+ /// tradeoff vs. either caching nothing or scanning live every time
+ /// the popover opens.
+ private(set) static var cache: [Patch] = []
+
+ /// True iff at least one loadable patch was found. Drives whether the
+ /// popover offers the GM/GarageBand toggle at all.
+ static var isAvailable: Bool { !cache.isEmpty }
+
+ /// Patches grouped by family, families sorted alphabetically, patches
+ /// inside each family sorted by display name. Stable order so the
+ /// popover list doesn't shuffle between launches.
+ static var groupedByFamily: [(family: String, patches: [Patch])] {
+ let groups = Dictionary(grouping: cache, by: { $0.family })
+ return groups.keys.sorted().map { family in
+ let sorted = groups[family]!.sorted { $0.displayName < $1.displayName }
+ return (family, sorted)
+ }
+ }
+
+ /// Scan + pre-flight every `.exs` under `roots`. Fills `cache`.
+ /// Called once during `MenuBandController.bootstrap` on a background
+ /// queue so we don't block app launch on the load-test pass.
+ static func scan() {
+ // Disposable engine + sampler: we never play through it, just
+ // probe whether each EXS loads. Released as soon as the scan is
+ // done so we don't keep a second audio graph alive forever.
+ let engine = AVAudioEngine()
+ let sampler = AVAudioUnitSampler()
+ engine.attach(sampler)
+ engine.connect(sampler, to: engine.mainMixerNode, format: nil)
+ do { try engine.start() } catch {
+ NSLog("MenuBand: GB library scan engine start failed: \(error)")
+ return
+ }
+ defer { engine.stop() }
+
+ var found: [Patch] = []
+ for root in roots {
+ let rootURL = URL(fileURLWithPath: root)
+ guard FileManager.default.fileExists(atPath: rootURL.path) else { continue }
+ guard let enumerator = FileManager.default.enumerator(
+ at: rootURL,
+ includingPropertiesForKeys: [.isRegularFileKey],
+ options: [.skipsHiddenFiles]
+ ) else { continue }
+ for case let url as URL in enumerator {
+ guard url.pathExtension.lowercased() == "exs" else { continue }
+ do {
+ try sampler.loadInstrument(at: url)
+ } catch {
+ continue // patch isn't loadable; skip silently
+ }
+ let parent = url.deletingLastPathComponent().lastPathComponent
+ // Patches living directly under "Sampler Instruments"
+ // get a friendlier family label. Nested folders (Church
+ // Organ, iOS Instruments) keep their actual folder name.
+ let family = (parent == "Sampler Instruments") ? "Sampler Instruments" : parent
+ let name = url.deletingPathExtension().lastPathComponent
+ found.append(Patch(family: family, displayName: name, url: url))
+ }
+ }
+ cache = found
+ NSLog("MenuBand: GB library scan — \(found.count) loadable patches across \(Set(found.map(\.family)).count) families")
+ }
+}
diff --git a/slab/menuband/Sources/MenuBand/GarageBandPatchView.swift b/slab/menuband/Sources/MenuBand/GarageBandPatchView.swift
new file mode 100644
index 000000000..8aee59afc
--- /dev/null
+++ b/slab/menuband/Sources/MenuBand/GarageBandPatchView.swift
@@ -0,0 +1,223 @@
+import AppKit
+
+/// Family-grouped scrollable list of GarageBand sampler patches. Mirrors
+/// the API of `InstrumentListView` — `onCommit`, `onHover`, and the
+/// `selectedPatchURL` highlight — so the popover can swap between the
+/// two views in the same physical rectangle without re-architecting the
+/// surrounding UI.
+///
+/// Layout: a vertical `NSStackView` of section blocks. Each block is a
+/// "FAMILY NAME" header label followed by one row per patch. Rows are
+/// click + hover targets; the active patch is rendered with the system
+/// accent fill, hovered rows tint lightly.
+final class GarageBandPatchView: NSView {
+ /// Click commit. Receives the URL of the picked patch.
+ var onCommit: ((URL) -> Void)?
+ /// Hover preview. Same press-gated semantics as `InstrumentListView`
+ /// — only fires while the user is dragging with the mouse held.
+ var onHover: ((URL?) -> Void)?
+
+ /// Highlight the row matching this URL. Set by the controller after
+ /// `onCommit` fires (and on initial show from saved state). nil =
+ /// nothing selected.
+ var selectedPatchURL: URL? {
+ didSet { needsDisplay = true; refreshRowHighlights() }
+ }
+
+ /// Match the GM grid's footprint so the popover doesn't reflow when
+ /// switching backends.
+ static let preferredWidth: CGFloat = InstrumentListView.preferredWidth
+ static let preferredHeight: CGFloat = InstrumentListView.preferredHeight
+
+ private let scrollView = NSScrollView()
+ private let documentView = NSView()
+ private var rows: [PatchRow] = []
+ private var dragging = false
+
+ override init(frame frameRect: NSRect) {
+ super.init(frame: frameRect)
+ wantsLayer = true
+ layer?.cornerRadius = 4
+ layer?.backgroundColor = NSColor.windowBackgroundColor.cgColor
+ layer?.borderColor = NSColor.separatorColor.cgColor
+ layer?.borderWidth = 0.5
+
+ scrollView.translatesAutoresizingMaskIntoConstraints = false
+ scrollView.hasVerticalScroller = true
+ scrollView.scrollerStyle = .overlay
+ scrollView.drawsBackground = false
+ scrollView.contentView.drawsBackground = false
+ scrollView.documentView = documentView
+ addSubview(scrollView)
+ NSLayoutConstraint.activate([
+ scrollView.leadingAnchor.constraint(equalTo: leadingAnchor),
+ scrollView.trailingAnchor.constraint(equalTo: trailingAnchor),
+ scrollView.topAnchor.constraint(equalTo: topAnchor),
+ scrollView.bottomAnchor.constraint(equalTo: bottomAnchor),
+ ])
+
+ rebuild()
+ }
+ required init?(coder: NSCoder) { fatalError() }
+
+ override var intrinsicContentSize: NSSize {
+ NSSize(width: Self.preferredWidth, height: Self.preferredHeight)
+ }
+
+ /// Rebuild the row list from `GarageBandLibrary.cache`. Called once
+ /// on init; can be re-called if we ever support live re-scanning.
+ private func rebuild() {
+ documentView.subviews.forEach { $0.removeFromSuperview() }
+ rows.removeAll()
+ let groups = GarageBandLibrary.groupedByFamily
+ let stack = NSStackView()
+ stack.translatesAutoresizingMaskIntoConstraints = false
+ stack.orientation = .vertical
+ stack.alignment = .leading
+ stack.spacing = 0
+ stack.edgeInsets = NSEdgeInsets(top: 4, left: 0, bottom: 6, right: 0)
+ for group in groups {
+ let header = NSTextField(labelWithString: group.family.uppercased())
+ header.font = NSFont.systemFont(ofSize: 9, weight: .bold)
+ header.textColor = .secondaryLabelColor
+ let headerWrap = NSView()
+ headerWrap.translatesAutoresizingMaskIntoConstraints = false
+ headerWrap.addSubview(header)
+ header.translatesAutoresizingMaskIntoConstraints = false
+ NSLayoutConstraint.activate([
+ header.leadingAnchor.constraint(equalTo: headerWrap.leadingAnchor, constant: 8),
+ header.topAnchor.constraint(equalTo: headerWrap.topAnchor, constant: 6),
+ header.bottomAnchor.constraint(equalTo: headerWrap.bottomAnchor, constant: -2),
+ headerWrap.widthAnchor.constraint(equalToConstant: Self.preferredWidth),
+ ])
+ stack.addArrangedSubview(headerWrap)
+
+ for patch in group.patches {
+ let row = PatchRow(patch: patch, parent: self)
+ rows.append(row)
+ stack.addArrangedSubview(row)
+ row.widthAnchor.constraint(equalToConstant: Self.preferredWidth).isActive = true
+ }
+ }
+ documentView.addSubview(stack)
+ NSLayoutConstraint.activate([
+ stack.topAnchor.constraint(equalTo: documentView.topAnchor),
+ stack.leadingAnchor.constraint(equalTo: documentView.leadingAnchor),
+ stack.trailingAnchor.constraint(equalTo: documentView.trailingAnchor),
+ stack.bottomAnchor.constraint(equalTo: documentView.bottomAnchor),
+ documentView.widthAnchor.constraint(equalToConstant: Self.preferredWidth),
+ ])
+ }
+
+ fileprivate func refreshRowHighlights() {
+ for row in rows { row.needsDisplay = true }
+ }
+
+ // MARK: - Press-gated mouse handling
+ //
+ // Same model as InstrumentMapView: passive hover does nothing;
+ // mouseDown arms drag-browse, mouseDragged updates hover/preview,
+ // mouseUp commits. The PatchRow forwards events up to here so the
+ // gesture state lives in one place.
+
+ fileprivate func didPressDown(at point: NSPoint) {
+ dragging = true
+ forwardHover(at: point)
+ }
+
+ fileprivate func didDrag(to point: NSPoint) {
+ guard dragging else { return }
+ forwardHover(at: point)
+ }
+
+ fileprivate func didMouseUp(at point: NSPoint) {
+ guard dragging else { return }
+ dragging = false
+ onHover?(nil)
+ if let row = rowAt(point: point) {
+ onCommit?(row.patch.url)
+ selectedPatchURL = row.patch.url
+ } else {
+ // Unhighlight all
+ for r in rows { r.isHovered = false }
+ }
+ }
+
+ private func forwardHover(at point: NSPoint) {
+ let hit = rowAt(point: point)
+ for r in rows { r.isHovered = (r === hit) }
+ onHover?(hit?.patch.url)
+ }
+
+ private func rowAt(point: NSPoint) -> PatchRow? {
+ // `point` is in our coordinate space; rows live inside the
+ // scroll's documentView, so convert through.
+ let docPt = documentView.convert(point, from: self)
+ return rows.first { $0.frame.contains(docPt) }
+ }
+}
+
+/// A single row in the patch list. Draws its own background so we don't
+/// have to rebuild the whole stack just to update one highlight.
+private final class PatchRow: NSView {
+ let patch: GarageBandLibrary.Patch
+ weak var parent: GarageBandPatchView?
+ var isHovered: Bool = false {
+ didSet { if oldValue != isHovered { needsDisplay = true } }
+ }
+
+ private let nameLabel = NSTextField(labelWithString: "")
+
+ init(patch: GarageBandLibrary.Patch, parent: GarageBandPatchView) {
+ self.patch = patch
+ self.parent = parent
+ super.init(frame: NSRect(x: 0, y: 0, width: 224, height: 22))
+ translatesAutoresizingMaskIntoConstraints = false
+ wantsLayer = true
+ nameLabel.translatesAutoresizingMaskIntoConstraints = false
+ nameLabel.stringValue = patch.displayName
+ nameLabel.font = NSFont.systemFont(ofSize: 11)
+ nameLabel.textColor = .labelColor
+ nameLabel.lineBreakMode = .byTruncatingTail
+ addSubview(nameLabel)
+ NSLayoutConstraint.activate([
+ heightAnchor.constraint(equalToConstant: 22),
+ nameLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 10),
+ nameLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8),
+ nameLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
+ ])
+ }
+ required init?(coder: NSCoder) { fatalError() }
+
+ override func draw(_ dirtyRect: NSRect) {
+ let isSelected = parent?.selectedPatchURL == patch.url
+ if isSelected {
+ NSColor.controlAccentColor.withAlphaComponent(0.85).setFill()
+ bounds.fill()
+ nameLabel.textColor = .white
+ } else if isHovered {
+ NSColor.controlAccentColor.withAlphaComponent(0.20).setFill()
+ bounds.fill()
+ nameLabel.textColor = .labelColor
+ } else {
+ nameLabel.textColor = .labelColor
+ }
+ }
+
+ override func mouseDown(with event: NSEvent) {
+ let pt = (parent ?? self).convert(event.locationInWindow, from: nil)
+ parent?.didPressDown(at: parent?.convert(pt, from: parent) ?? pt)
+ // Translate the press into a hit on this row directly — that's
+ // the simpler path; parent.didPressDown rehits via geometry.
+ }
+ override func mouseDragged(with event: NSEvent) {
+ guard let parent = parent else { return }
+ let pt = parent.convert(event.locationInWindow, from: nil)
+ parent.didDrag(to: pt)
+ }
+ override func mouseUp(with event: NSEvent) {
+ guard let parent = parent else { return }
+ let pt = parent.convert(event.locationInWindow, from: nil)
+ parent.didMouseUp(at: pt)
+ }
+}
diff --git a/system/public/menuband/index.html b/system/public/menuband/index.html
index 0e4ed9bc8..af82fa86e 100644
--- a/system/public/menuband/index.html
+++ b/system/public/menuband/index.html
@@ -571,9 +571,9 @@
Taking macOS' standard instruments out of the 🎸 Garage and kickin' it on the curb!
view source · by aesthetic.computer
@@ -598,8 +598,10 @@
- What's new 0.3
- 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.
+ What's new 0.5
+ Snappier. 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 Esteban Uribe for both performance patches.
+ No Accessibility prompt. 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.)
+ Plus: a mute toggle, an accent-tinted Finder icon that follows your system color, and the popover keeps the same width when MIDI flips on.